├── config ├── flog.yml ├── yardstick.yml ├── devtools.yml ├── flay.yml ├── mutant.yml ├── reek.yml └── rubocop.yml ├── .rubocop.yml ├── lib ├── devtools │ ├── spec_helper.rb │ ├── project │ │ ├── initializer.rb │ │ └── initializer │ │ │ ├── rake.rb │ │ │ └── rspec.rb │ ├── project.rb │ ├── flay.rb │ ├── rake │ │ └── flay.rb │ └── config.rb └── devtools.rb ├── Rakefile ├── .rspec ├── shared └── spec │ ├── shared │ ├── command_method_behavior.rb │ ├── each_method_behaviour.rb │ ├── idempotent_method_behavior.rb │ └── abstract_type_behavior.rb │ └── support │ └── ice_nine_config.rb ├── tasks ├── yard.rake ├── metrics │ ├── reek.rake │ ├── ci.rake │ ├── yardstick.rake │ ├── coverage.rake │ ├── rubocop.rake │ ├── flay.rake │ └── flog.rake └── spec.rake ├── Gemfile ├── spec ├── unit │ ├── devtools_spec.rb │ └── devtools │ │ ├── flay │ │ ├── scale │ │ │ ├── flay_report_spec.rb │ │ │ └── measure_spec.rb │ │ └── file_list │ │ │ └── call_spec.rb │ │ ├── config │ │ └── yardstick_spec.rb │ │ ├── project │ │ └── initializer │ │ │ ├── rake_spec.rb │ │ │ └── rspec_spec.rb │ │ ├── project_spec.rb │ │ └── config_spec.rb ├── spec_helper.rb └── integration │ └── devtools │ └── rake │ └── flay │ └── verify_spec.rb ├── .gitignore ├── .circleci └── config.yml ├── LICENSE ├── README.md └── devtools.gemspec /config/flog.yml: -------------------------------------------------------------------------------- 1 | --- 2 | threshold: 13.5 3 | -------------------------------------------------------------------------------- /config/yardstick.yml: -------------------------------------------------------------------------------- 1 | --- 2 | threshold: 100 3 | -------------------------------------------------------------------------------- /config/devtools.yml: -------------------------------------------------------------------------------- 1 | --- 2 | unit_test_timeout: 0.2 3 | -------------------------------------------------------------------------------- /config/flay.yml: -------------------------------------------------------------------------------- 1 | --- 2 | threshold: 8 3 | total_score: 119 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisplayCopNames: true 3 | Include: 4 | - 'Gemfile' 5 | -------------------------------------------------------------------------------- /lib/devtools/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'devtools' 2 | 3 | Devtools::PROJECT.init_rspec 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'devtools' 2 | 3 | ENV['DEVTOOLS_SELF'] = '1' 4 | 5 | Devtools.init_rake_tasks 6 | -------------------------------------------------------------------------------- /config/mutant.yml: -------------------------------------------------------------------------------- 1 | --- 2 | includes: 3 | - lib 4 | integration: rspec 5 | requires: 6 | - devtools 7 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | --profile 4 | --warnings 5 | --order random 6 | --require spec_helper 7 | -------------------------------------------------------------------------------- /shared/spec/shared/command_method_behavior.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for 'a command method' do 2 | it 'returns self' do 3 | should equal(object) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /tasks/yard.rake: -------------------------------------------------------------------------------- 1 | begin 2 | require 'yard' 3 | 4 | YARD::Rake::YardocTask.new 5 | rescue LoadError 6 | task :yard do 7 | warn 'In order to run yard, you must: gem install yard' 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /tasks/metrics/reek.rake: -------------------------------------------------------------------------------- 1 | namespace :metrics do 2 | require 'reek/rake/task' 3 | 4 | Reek::Rake::Task.new do |reek| 5 | reek.source_files = '{app,lib}/**/*.rb' 6 | reek.config_file = 'config/reek.yml' 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | group :development do 6 | gem 'mutant' 7 | gem 'mutant-rspec' 8 | 9 | source 'https://oss:Px2ENN7S91OmWaD5G7MIQJi1dmtmYrEh@gem.mutant.dev' do 10 | gem 'mutant-license' 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /shared/spec/support/ice_nine_config.rb: -------------------------------------------------------------------------------- 1 | if defined?(IceNine) 2 | module IceNine 3 | 4 | # Freezer namespace 5 | class Freezer 6 | 7 | # Rspec freezer 8 | class RSpec < NoFreeze; end 9 | 10 | end # Freezer 11 | end # IceNine 12 | end 13 | -------------------------------------------------------------------------------- /lib/devtools/project/initializer.rb: -------------------------------------------------------------------------------- 1 | module Devtools 2 | class Project 3 | 4 | # Base class for project initializers 5 | class Initializer 6 | include AbstractType 7 | 8 | abstract_singleton_method :call 9 | end # class Initializer 10 | end # class Project 11 | end # module Devtools 12 | -------------------------------------------------------------------------------- /tasks/metrics/ci.rake: -------------------------------------------------------------------------------- 1 | desc 'Run all specs and metrics' 2 | task ci: %w[ci:metrics] 3 | 4 | namespace :ci do 5 | tasks = %w[ 6 | metrics:coverage 7 | metrics:yardstick:verify 8 | metrics:rubocop 9 | metrics:flog 10 | metrics:flay 11 | metrics:reek 12 | spec:integration 13 | ] 14 | end 15 | -------------------------------------------------------------------------------- /tasks/metrics/yardstick.rake: -------------------------------------------------------------------------------- 1 | namespace :metrics do 2 | namespace :yardstick do 3 | require 'yardstick/rake/measurement' 4 | require 'yardstick/rake/verify' 5 | 6 | options = Devtools.project.yardstick.options 7 | 8 | Yardstick::Rake::Measurement.new(:measure, options) 9 | 10 | Yardstick::Rake::Verify.new(:verify, options) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /shared/spec/shared/each_method_behaviour.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for 'an #each method' do 2 | it_should_behave_like 'a command method' 3 | 4 | context 'with no block' do 5 | subject { object.each } 6 | 7 | it { should be_instance_of(to_enum.class) } 8 | 9 | it 'yields the expected values' do 10 | expect(subject.to_a).to eql(object.to_a) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/unit/devtools_spec.rb: -------------------------------------------------------------------------------- 1 | describe Devtools do 2 | describe '.project' do 3 | specify do 4 | expect(Devtools.project).to equal(Devtools::PROJECT) 5 | end 6 | end 7 | 8 | describe '.init_rake_tasks' do 9 | specify do 10 | expect(Devtools::Project::Initializer::Rake).to receive(:call) 11 | expect(Devtools.init_rake_tasks).to be(Devtools) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /tasks/metrics/coverage.rake: -------------------------------------------------------------------------------- 1 | namespace :metrics do 2 | desc 'Measure code coverage' 3 | task :coverage do 4 | begin 5 | # rubocop:disable Style/ParallelAssignment 6 | original, ENV['COVERAGE'] = ENV['COVERAGE'], 'true' 7 | # rubocop:enable Style/ParallelAssignment 8 | Rake::Task['spec:unit'].execute 9 | ensure 10 | ENV['COVERAGE'] = original 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## MAC OS 2 | .DS_Store 3 | 4 | ## TEXTMATE 5 | *.tmproj 6 | tmtags 7 | 8 | ## EMACS 9 | *~ 10 | \#* 11 | .\#* 12 | 13 | ## VIM 14 | *.sw[op] 15 | 16 | ## Rubinius 17 | *.rbc 18 | .rbx 19 | 20 | ## PROJECT::GENERAL 21 | *.gem 22 | coverage 23 | profiling 24 | turbulence 25 | rdoc 26 | pkg 27 | tmp 28 | doc 29 | log 30 | .yardoc 31 | measurements 32 | 33 | ## BUNDLER 34 | .bundle 35 | Gemfile.lock 36 | 37 | ## PROJECT::SPECIFIC 38 | -------------------------------------------------------------------------------- /shared/spec/shared/idempotent_method_behavior.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for 'an idempotent method' do 2 | it 'is idempotent' do 3 | first = subject 4 | error = 'RSpec not configured for threadsafety' 5 | fail error unless RSpec.configuration.threadsafe? 6 | mutex = __memoized.instance_variable_get(:@mutex) 7 | memoized = __memoized.instance_variable_get(:@memoized) 8 | mutex.synchronize { memoized.delete(:subject) } 9 | should equal(first) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /tasks/metrics/rubocop.rake: -------------------------------------------------------------------------------- 1 | namespace :metrics do 2 | desc 'Check with code style guide' 3 | task :rubocop do 4 | require 'rubocop' 5 | config = Devtools.project.rubocop 6 | begin 7 | exit_status = RuboCop::CLI.new.run(%W[--config #{config.config_file}]) 8 | fail 'Rubocop not successful' unless exit_status.zero? 9 | rescue Encoding::CompatibilityError => exception 10 | Devtools.notify_metric_violation exception.message 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /shared/spec/shared/abstract_type_behavior.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for 'an abstract type' do 2 | context 'called on a subclass' do 3 | let(:object) { Class.new(described_class) } 4 | 5 | it { should be_instance_of(object) } 6 | end 7 | 8 | context 'called on the class' do 9 | let(:object) { described_class } 10 | 11 | specify do 12 | expect { subject } 13 | .to raise_error(NotImplementedError, "#{object} is an abstract type") 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/unit/devtools/flay/scale/flay_report_spec.rb: -------------------------------------------------------------------------------- 1 | describe Devtools::Flay::Scale, '#flay_report' do 2 | subject(:instance) { described_class.new(minimum_mass: 0, files: []) } 3 | 4 | let(:flay) do 5 | instance_double(::Flay, process: nil, analyze: nil, masses: {}) 6 | end 7 | 8 | before do 9 | allow(::Flay).to receive(:new).with(mass: 0).and_return(flay) 10 | end 11 | 12 | specify do 13 | allow(flay).to receive(:report) 14 | instance.flay_report 15 | expect(flay).to have_received(:report) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/unit/devtools/config/yardstick_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Devtools::Config::Yardstick do 2 | let(:object) { described_class.new(Devtools.root.join('config')) } 3 | 4 | describe '#options' do 5 | subject { object.options } 6 | 7 | specify do 8 | should eql( 9 | 'threshold' => 100, 10 | 'rules' => nil, 11 | 'verbose' => nil, 12 | 'path' => nil, 13 | 'require_exact_threshold' => nil 14 | ) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /tasks/metrics/flay.rake: -------------------------------------------------------------------------------- 1 | namespace :metrics do 2 | require 'flay' 3 | 4 | project = Devtools.project 5 | config = project.flay 6 | 7 | # Original code by Marty Andrews: 8 | # http://blog.martyandrews.net/2009/05/enforcing-ruby-code-quality.html 9 | desc 'Measure code duplication' 10 | task :flay do 11 | threshold = config.threshold 12 | total_score = config.total_score 13 | 14 | Devtools::Rake::Flay.call( 15 | threshold: threshold, 16 | total_score: total_score, 17 | lib_dirs: config.lib_dirs, 18 | excludes: config.excludes 19 | ) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'devtools/spec_helper' 2 | require 'tempfile' 3 | require 'tmpdir' 4 | 5 | if ENV['COVERAGE'] == 'true' 6 | formatter = [SimpleCov::Formatter::HTMLFormatter] 7 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new(formatter) 8 | 9 | SimpleCov.start do 10 | command_name 'spec:unit' 11 | 12 | add_filter 'config' 13 | add_filter 'spec' 14 | add_filter 'vendor' 15 | 16 | minimum_coverage 100 17 | end 18 | end 19 | 20 | RSpec.configure do |config| 21 | config.expect_with :rspec do |expect_with| 22 | expect_with.syntax = :expect 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/devtools/project/initializer/rake.rb: -------------------------------------------------------------------------------- 1 | module Devtools 2 | class Project 3 | class Initializer 4 | # Imports all devtools rake tasks into a project 5 | class Rake < self 6 | include AbstractType 7 | 8 | # Initialize rake tasks 9 | # 10 | # @return [undefined] 11 | # 12 | # @api rpivate 13 | def self.call 14 | FileList 15 | .glob(RAKE_FILES_GLOB) 16 | .each(&::Rake.application.method(:add_import)) 17 | self 18 | end 19 | 20 | end # class Rake 21 | end # class Initializer 22 | end # class Project 23 | end # module Devtools 24 | -------------------------------------------------------------------------------- /spec/unit/devtools/flay/file_list/call_spec.rb: -------------------------------------------------------------------------------- 1 | describe Devtools::Flay::FileList, '.call' do 2 | subject(:output) { described_class.call([tmpdir.to_s].freeze, [exclude]) } 3 | 4 | let(:tmpdir) { Dir.mktmpdir } 5 | let(:one) { Pathname(tmpdir).join('1.rb') } 6 | let(:two) { Pathname(tmpdir).join('2.erb') } 7 | let(:three) { Pathname(tmpdir).join('3.rb') } 8 | let(:exclude) { Pathname(tmpdir).join('3*').to_s } 9 | 10 | around(:each) do |example| 11 | [one, two, three].map(&FileUtils.method(:touch)) 12 | 13 | example.run 14 | 15 | FileUtils.rm_rf(tmpdir) 16 | end 17 | 18 | it { should eql(Set.new([one, two])) } 19 | end 20 | -------------------------------------------------------------------------------- /spec/unit/devtools/project/initializer/rake_spec.rb: -------------------------------------------------------------------------------- 1 | describe Devtools::Project::Initializer::Rake do 2 | describe '.call' do 3 | subject do 4 | described_class.call 5 | end 6 | 7 | it 'performs expected rake initialization' do 8 | path_a = instance_double(Pathname) 9 | path_b = instance_double(Pathname) 10 | 11 | expect(FileList).to receive(:glob) 12 | .with(Devtools.root.join('tasks/**/*.rake').to_s) 13 | .and_return([path_a, path_b]) 14 | 15 | expect(Rake.application).to receive(:add_import).with(path_a) 16 | expect(Rake.application).to receive(:add_import).with(path_b) 17 | 18 | expect(subject).to be(described_class) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /tasks/spec.rake: -------------------------------------------------------------------------------- 1 | begin 2 | require 'rspec/core/rake_task' 3 | 4 | # Remove existing same-named tasks 5 | %w[spec spec:unit spec:integration].each do |task| 6 | klass = Rake::Task 7 | klass[task].clear if klass.task_defined?(task) 8 | end 9 | 10 | desc 'Run all specs' 11 | RSpec::Core::RakeTask.new(:spec) do |task| 12 | task.pattern = 'spec/{unit,integration}/**/*_spec.rb' 13 | end 14 | 15 | namespace :spec do 16 | desc 'Run unit specs' 17 | RSpec::Core::RakeTask.new(:unit) do |task| 18 | task.pattern = 'spec/unit/**/*_spec.rb' 19 | end 20 | 21 | desc 'Run integration specs' 22 | RSpec::Core::RakeTask.new(:integration) do |task| 23 | task.pattern = 'spec/integration/**/*_spec.rb' 24 | end 25 | end 26 | rescue LoadError 27 | %w[spec spec:unit spec:integration].each do |name| 28 | task name do 29 | warn "In order to run #{name}, do: gem install rspec" 30 | end 31 | end 32 | end 33 | 34 | task test: :spec 35 | -------------------------------------------------------------------------------- /spec/unit/devtools/project_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Devtools::Project do 2 | let(:object) { described_class.new(Devtools.root) } 3 | 4 | describe '#init_rspec' do 5 | subject { object.init_rspec } 6 | 7 | it 'calls the rspec initializer' do 8 | expect(Devtools::Project::Initializer::Rspec) 9 | .to receive(:call).with(Devtools.project) 10 | expect(subject).to be(object) 11 | end 12 | end 13 | 14 | { 15 | devtools: Devtools::Config::Devtools, 16 | flay: Devtools::Config::Flay, 17 | flog: Devtools::Config::Flog, 18 | reek: Devtools::Config::Reek, 19 | rubocop: Devtools::Config::Rubocop, 20 | yardstick: Devtools::Config::Yardstick 21 | }.each do |name, klass| 22 | describe "##{name}" do 23 | subject { object.send(name) } 24 | 25 | specify { should eql(klass.new(Devtools.root.join('config'))) } 26 | end 27 | end 28 | 29 | describe '#spec_root' do 30 | subject { object.spec_root } 31 | 32 | specify { should eql(Devtools.root.join('spec')) } 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | working_directory: ~/mutant 3 | docker: 4 | - image: circleci/ruby:2.6.0 5 | version: 2 6 | jobs: 7 | unit_specs: 8 | <<: *defaults 9 | steps: 10 | - checkout 11 | - run: bundle install 12 | - run: bundle exec rspec spec/unit 13 | integration_specs: 14 | <<: *defaults 15 | steps: 16 | - checkout 17 | - run: bundle install 18 | - run: bundle exec rspec spec/integration 19 | metrics: 20 | <<: *defaults 21 | steps: 22 | - checkout 23 | - run: bundle install 24 | - run: bundle exec rake metrics:rubocop 25 | - run: bundle exec rake metrics:reek 26 | - run: bundle exec rake metrics:flay 27 | - run: bundle exec rake metrics:flog 28 | mutant: 29 | <<: *defaults 30 | steps: 31 | - checkout 32 | - run: bundle install 33 | - run: bundle exec mutant --since HEAD~1 -- 'Devtools*' 34 | workflows: 35 | version: 2 36 | test: 37 | jobs: 38 | - unit_specs 39 | - integration_specs 40 | - metrics 41 | - mutant 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Markus Schirp 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /spec/unit/devtools/flay/scale/measure_spec.rb: -------------------------------------------------------------------------------- 1 | describe Devtools::Flay::Scale, '#measure' do 2 | subject(:measure) { instance.measure } 3 | 4 | let(:minimum_mass) { 0 } 5 | let(:files) { [instance_double(File)] } 6 | let(:flay_masses) { { 0 => 5, 1 => 10 } } 7 | 8 | let(:instance) do 9 | described_class.new(minimum_mass: minimum_mass, files: files) 10 | end 11 | 12 | let(:flay_hashes) do 13 | { 14 | 0 => instance_double(Array, size: 3), 15 | 1 => instance_double(Array, size: 11) 16 | } 17 | end 18 | 19 | let(:flay) do 20 | instance_double( 21 | ::Flay, 22 | analyze: nil, 23 | masses: flay_masses, 24 | hashes: flay_hashes 25 | ) 26 | end 27 | 28 | before do 29 | allow(::Flay).to receive(:new).with(mass: minimum_mass).and_return(flay) 30 | allow(flay).to receive(:process).with(*files) 31 | end 32 | 33 | it { should eql([Rational(5, 3), Rational(10, 11)]) } 34 | 35 | context 'when minimum mass is not 0' do 36 | let(:minimum_mass) { 1 } 37 | 38 | specify do 39 | measure 40 | expect(::Flay).to have_received(:new).with(mass: 1) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /tasks/metrics/flog.rake: -------------------------------------------------------------------------------- 1 | # rubocop:disable Metrics/BlockLength 2 | namespace :metrics do 3 | require 'flog' 4 | require 'flog_cli' 5 | 6 | project = Devtools.project 7 | config = project.flog 8 | 9 | # Original code by Marty Andrews: 10 | # http://blog.martyandrews.net/2009/05/enforcing-ruby-code-quality.html 11 | desc 'Measure code complexity' 12 | task :flog do 13 | threshold = config.threshold.to_f.round(1) 14 | flog = Flog.new 15 | flog.flog(*PathExpander.new(config.lib_dirs.dup, '**/*.rb').process) 16 | 17 | totals = flog 18 | .totals 19 | .reject { |name, _score| name.end_with?('#none') } 20 | .map { |name, score| [name, score.round(1)] } 21 | .sort_by { |_name, score| score } 22 | 23 | if totals.any? 24 | max = totals.last[1] 25 | unless max >= threshold 26 | Devtools.notify_metric_violation "Adjust flog score down to #{max}" 27 | end 28 | end 29 | 30 | bad_methods = totals.select { |_name, score| score > threshold } 31 | 32 | if bad_methods.any? 33 | bad_methods.reverse_each do |name, score| 34 | printf "%8.1f: %s\n", score, name 35 | end 36 | 37 | Devtools.notify_metric_violation( 38 | "#{bad_methods.size} methods have a flog complexity > #{threshold}" 39 | ) 40 | end 41 | end 42 | end 43 | # rubocop:enable Metrics/BlockLength 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # devtools 2 | 3 | [![Build Status](https://img.shields.io/circleci/project/mbj/devtools.svg)](https://circleci.com/gh/mbj/devtools/tree/master) 4 | [![Dependency Status](https://gemnasium.com/mbj/devtools.png)](https://gemnasium.com/mbj/devtools) 5 | [![Code Climate](https://codeclimate.com/github/datamapper/devtools.png)](https://codeclimate.com/github/datamapper/devtools) 6 | 7 | 8 | Metagem to assist development. 9 | Used to centralize metric setup and development gem dependencies. 10 | 11 | ## Installation 12 | 13 | Add the gem to your Gemfile's development section. 14 | 15 | ```ruby 16 | group :development, :test do 17 | gem 'devtools', '~> 0.1.x' 18 | end 19 | ``` 20 | 21 | ## RSpec support 22 | 23 | If you're using RSpec and want to have access to our common setup just adjust 24 | `spec/spec_helper.rb` to include 25 | 26 | ```ruby 27 | require 'devtools/spec_helper' 28 | ``` 29 | 30 | ## Credits 31 | 32 | The whole [ROM](https://github.com/rom-rb) team that created and maintained all 33 | these tasks before they were centralized here. 34 | 35 | ## Contributing 36 | 37 | * Fork the project. 38 | * Make your feature addition or bug fix. 39 | * Add tests for it. This is important so I don't break it in a 40 | future version unintentionally. 41 | * Commit, do not mess with Rakefile or version 42 | (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull) 43 | * Send me a pull request. Bonus points for topic branches. 44 | 45 | ## License 46 | 47 | See `LICENSE` file. 48 | -------------------------------------------------------------------------------- /devtools.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |gem| 2 | gem.name = 'devtools' 3 | gem.version = '0.1.26' 4 | gem.authors = ['Markus Schirp'] 5 | gem.email = ['mbj@schirp-dso.com'] 6 | gem.description = 'A metagem wrapping development tools' 7 | gem.summary = gem.description 8 | gem.homepage = 'https://github.com/rom-rb/devtools' 9 | gem.license = 'MIT' 10 | 11 | gem.require_paths = %w[lib] 12 | gem.files = `git ls-files`.split("\n") 13 | gem.executables = %w[] 14 | gem.test_files = `git ls-files -- spec`.split("\n") 15 | gem.extra_rdoc_files = %w[README.md] 16 | 17 | gem.add_runtime_dependency 'abstract_type', '~> 0.0.7' 18 | gem.add_runtime_dependency 'adamantium', '~> 0.2.0' 19 | gem.add_runtime_dependency 'anima', '~> 0.3.0' 20 | gem.add_runtime_dependency 'concord', '~> 0.1.5' 21 | gem.add_runtime_dependency 'flay', '~> 2.12.0' 22 | gem.add_runtime_dependency 'flog', '~> 4.6.2' 23 | gem.add_runtime_dependency 'procto', '~> 0.0.3' 24 | gem.add_runtime_dependency 'rake', '~> 12.3.0' 25 | gem.add_runtime_dependency 'reek', '~> 5.6.0' 26 | gem.add_runtime_dependency 'rspec', '~> 3.8.0' 27 | gem.add_runtime_dependency 'rspec-core', '~> 3.8.0' 28 | gem.add_runtime_dependency 'rspec-its', '~> 1.2.0' 29 | gem.add_runtime_dependency 'rubocop', '~> 0.79.0' 30 | gem.add_runtime_dependency 'simplecov', '~> 0.16.1' 31 | gem.add_runtime_dependency 'yard', '~> 0.9.16' 32 | gem.add_runtime_dependency 'yardstick', '~> 0.9.9' 33 | 34 | gem.add_development_dependency 'mutant', '~> 0.9.4' 35 | end 36 | -------------------------------------------------------------------------------- /spec/unit/devtools/project/initializer/rspec_spec.rb: -------------------------------------------------------------------------------- 1 | describe Devtools::Project::Initializer::Rspec do 2 | let(:spec_root) { Devtools.root.join('spec') } 3 | let(:unit_test_timeout) { instance_double(Float) } 4 | 5 | let(:project) do 6 | instance_double( 7 | Devtools::Project, 8 | spec_root: spec_root, 9 | devtools: instance_double( 10 | Devtools::Config::Devtools, 11 | unit_test_timeout: unit_test_timeout 12 | ) 13 | ) 14 | end 15 | 16 | describe '.call' do 17 | subject do 18 | described_class.call(project) 19 | end 20 | 21 | it 'performs expected rspec initialization' do 22 | called = false 23 | example = -> { called = true } 24 | 25 | expect(Dir).to receive(:glob) 26 | .with(Devtools.root.join('shared/spec/{shared,support}/**/*.rb')) 27 | .and_return(%w[shared-a shared-b]) 28 | 29 | expect(Kernel).to receive(:require).with('shared-a') 30 | expect(Kernel).to receive(:require).with('shared-b') 31 | 32 | expect(Dir).to receive(:glob) 33 | .with(Devtools.root.join('spec/{shared,support}/**/*.rb')) 34 | .and_return(%w[support-a support-b]) 35 | 36 | expect(Kernel).to receive(:require).with('support-a') 37 | expect(Kernel).to receive(:require).with('support-b') 38 | 39 | expect(Timeout).to receive(:timeout).with(unit_test_timeout) do |&block| 40 | block.call 41 | end 42 | 43 | expect(RSpec.configuration).to receive(:around) 44 | .with(file_path: %r{\bspec/unit/}) 45 | .and_yield(example) 46 | 47 | expect(subject).to be(described_class) 48 | 49 | expect(called).to be(true) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/devtools/project.rb: -------------------------------------------------------------------------------- 1 | module Devtools 2 | 3 | # The project devtools supports 4 | class Project 5 | include Concord.new(:root) 6 | 7 | CONFIGS = { 8 | devtools: Config::Devtools, 9 | flay: Config::Flay, 10 | flog: Config::Flog, 11 | reek: Config::Reek, 12 | rubocop: Config::Rubocop, 13 | yardstick: Config::Yardstick 14 | }.freeze 15 | 16 | private_constant(*constants(false)) 17 | 18 | attr_reader(*CONFIGS.keys) 19 | 20 | # The spec root 21 | # 22 | # @return [Pathname] 23 | # 24 | # @api private 25 | attr_reader :spec_root 26 | 27 | # Initialize object 28 | # 29 | # @param [Pathname] root 30 | # 31 | # @return [undefined] 32 | # 33 | # @api private 34 | # 35 | def initialize(root) 36 | super(root) 37 | 38 | initialize_environment 39 | initialize_configs 40 | end 41 | 42 | # Init rspec 43 | # 44 | # @return [self] 45 | # 46 | # @api private 47 | def init_rspec 48 | Initializer::Rspec.call(self) 49 | self 50 | end 51 | 52 | private 53 | 54 | # Initialize environment 55 | # 56 | # @return [undefined] 57 | # 58 | # @api private 59 | # 60 | def initialize_environment 61 | @spec_root = root.join(SPEC_DIRECTORY_NAME) 62 | end 63 | 64 | # Initialize configs 65 | # 66 | # @return [undefined] 67 | # 68 | # @api private 69 | # 70 | def initialize_configs 71 | config_dir = root.join(DEFAULT_CONFIG_DIR_NAME) 72 | 73 | CONFIGS.each do |name, klass| 74 | instance_variable_set(:"@#{name}", klass.new(config_dir)) 75 | end 76 | end 77 | 78 | end # class Project 79 | end # module Devtools 80 | -------------------------------------------------------------------------------- /spec/unit/devtools/config_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Devtools::Config do 2 | 3 | describe '.attribute' do 4 | let(:raw) do 5 | { 6 | 'a' => 'bar', 7 | 'c' => [] 8 | } 9 | 10 | end 11 | 12 | let(:config_path) { instance_double(Pathname) } 13 | 14 | let(:class_under_test) do 15 | expect(config_path).to receive(:file?) 16 | .and_return(file?) 17 | expect(config_path).to receive(:frozen?) 18 | .and_return(true) 19 | expect(config_path).to receive(:join) 20 | .with('bar.yml') 21 | .and_return(config_path) 22 | 23 | Class.new(described_class) do 24 | attribute :a, [String] 25 | attribute :b, [Array], default: [] 26 | attribute :c, [TrueClass, FalseClass] 27 | 28 | const_set(:FILE, 'bar.yml') 29 | end 30 | end 31 | 32 | subject do 33 | class_under_test.new(config_path) 34 | end 35 | 36 | context 'on present config' do 37 | let(:class_under_test) do 38 | # Setup message expectation in a lasy way, not in a before 39 | # block to make sure the around hook setting timeouts from the 40 | # code under test is not affected. 41 | expect(YAML).to receive(:load_file) 42 | .with(config_path) 43 | .and_return(raw) 44 | 45 | expect(IceNine).to receive(:deep_freeze) 46 | .with(raw) 47 | .and_return(raw) 48 | 49 | super() 50 | end 51 | 52 | let(:file?) { true } 53 | 54 | it 'allows to receive existing keys' do 55 | expect(subject.a).to eql('bar') 56 | end 57 | 58 | it 'allows to receive absent keys with defaults' do 59 | expect(subject.b).to eql([]) 60 | end 61 | 62 | it 'executes checks when configured' do 63 | expect { subject.c }.to raise_error( 64 | Devtools::Config::TypeError, 65 | 'c: Got instance of Array expected TrueClass,FalseClass' 66 | ) 67 | end 68 | end 69 | 70 | context 'on absent config' do 71 | let(:file?) { false } 72 | 73 | it 'defaults to absent keys' do 74 | expect(subject.b).to eql([]) 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/devtools/project/initializer/rspec.rb: -------------------------------------------------------------------------------- 1 | module Devtools 2 | class Project 3 | class Initializer 4 | 5 | # Requires all shared specs in a project's spec_helper 6 | # Also installs a configurable unit test timeout 7 | class Rspec < self 8 | include Concord.new(:project) 9 | 10 | # Call initializer for project 11 | # 12 | # @param [Project] project 13 | # 14 | # @return [self] 15 | # 16 | # @api private 17 | def self.call(project) 18 | new(project).__send__(:call) 19 | self 20 | end 21 | 22 | private 23 | 24 | # Setup RSpec for project 25 | # 26 | # @return [self] 27 | # 28 | # @api private 29 | def call 30 | require_shared_spec_files 31 | enable_unit_test_timeout 32 | end 33 | 34 | # Timeout unit tests that take longer than configured amount of time 35 | # 36 | # @param [Numeric] timeout 37 | # 38 | # @return [undefined] 39 | # 40 | # @raise [Timeout::Error] 41 | # raised when the times are outside the timeout 42 | # 43 | # @api private 44 | # 45 | def enable_unit_test_timeout 46 | timeout = project.devtools.unit_test_timeout 47 | RSpec 48 | .configuration 49 | .around(file_path: UNIT_TEST_PATH_REGEXP) do |example| 50 | Timeout.timeout(timeout, &example) 51 | end 52 | end 53 | 54 | # Trigger the require of shared spec files 55 | # 56 | # @return [undefined] 57 | # 58 | # @api private 59 | # 60 | def require_shared_spec_files 61 | require_files(SHARED_SPEC_PATH) 62 | require_files(project.spec_root) 63 | end 64 | 65 | # Require files with pattern 66 | # 67 | # @param [Pathname] dir 68 | # the directory containing the files to require 69 | # 70 | # @return [self] 71 | # 72 | # @api private 73 | def require_files(dir) 74 | Dir.glob(dir.join(SHARED_SPEC_PATTERN)).each(&Kernel.method(:require)) 75 | end 76 | 77 | end # class Rspec 78 | end # class Initializer 79 | end # class Project 80 | end # module Devtools 81 | -------------------------------------------------------------------------------- /lib/devtools.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Stdlib infrastructure 4 | require 'pathname' 5 | require 'rake' 6 | require 'timeout' 7 | require 'yaml' 8 | require 'fileutils' 9 | 10 | # Non stdlib infrastructure 11 | require 'abstract_type' 12 | require 'procto' 13 | require 'anima' 14 | require 'concord' 15 | require 'adamantium' 16 | 17 | # Wrapped tools 18 | require 'flay' 19 | require 'rspec' 20 | require 'rspec/its' 21 | require 'simplecov' 22 | 23 | # Main devtools namespace population 24 | module Devtools 25 | ROOT = Pathname.new(__FILE__).parent.parent.freeze 26 | PROJECT_ROOT = Pathname.pwd.freeze 27 | SHARED_PATH = ROOT.join('shared').freeze 28 | SHARED_SPEC_PATH = SHARED_PATH.join('spec').freeze 29 | DEFAULT_CONFIG_PATH = ROOT.join('default/config').freeze 30 | RAKE_FILES_GLOB = ROOT.join('tasks/**/*.rake').to_s.freeze 31 | LIB_DIRECTORY_NAME = 'lib' 32 | SPEC_DIRECTORY_NAME = 'spec' 33 | RAKE_FILE_NAME = 'Rakefile' 34 | SHARED_SPEC_PATTERN = '{shared,support}/**/*.rb' 35 | UNIT_TEST_PATH_REGEXP = %r{\bspec/unit/} 36 | DEFAULT_CONFIG_DIR_NAME = 'config' 37 | 38 | private_constant(*constants(false)) 39 | 40 | # React to metric violation 41 | # 42 | # @param [String] msg 43 | # 44 | # @return [undefined] 45 | # 46 | # @api private 47 | def self.notify_metric_violation(msg) 48 | abort(msg) 49 | end 50 | 51 | # Initialize project and load tasks 52 | # 53 | # Should *only* be called from your $application_root/Rakefile 54 | # 55 | # @return [self] 56 | # 57 | # @api public 58 | def self.init_rake_tasks 59 | Project::Initializer::Rake.call 60 | self 61 | end 62 | 63 | # Return devtools root path 64 | # 65 | # @return [Pathname] 66 | # 67 | # @api private 68 | def self.root 69 | ROOT 70 | end 71 | 72 | # Return project 73 | # 74 | # @return [Project] 75 | # 76 | # @api private 77 | def self.project 78 | PROJECT 79 | end 80 | 81 | end # module Devtools 82 | 83 | # Devtools implementation 84 | require 'devtools/config' 85 | require 'devtools/project' 86 | require 'devtools/project/initializer' 87 | require 'devtools/project/initializer/rake' 88 | require 'devtools/project/initializer/rspec' 89 | require 'devtools/flay' 90 | require 'devtools/rake/flay' 91 | 92 | # Devtools self initialization 93 | module Devtools 94 | # The project devtools is active for 95 | PROJECT = Project.new(PROJECT_ROOT) 96 | end 97 | -------------------------------------------------------------------------------- /lib/devtools/flay.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Devtools 4 | module Flay 5 | # Measure flay mass relative to size of duplicated sexps 6 | class Scale 7 | include Adamantium 8 | include Anima.new(:minimum_mass, :files) 9 | include Procto.call(:measure) 10 | 11 | # Measure duplication mass 12 | # 13 | # @return [Array] 14 | # 15 | # @api private 16 | def measure 17 | flay.masses.map do |hash, mass| 18 | Rational(mass, flay.hashes.fetch(hash).size) 19 | end 20 | end 21 | 22 | # Report flay output 23 | # 24 | # @return [undefined] 25 | # 26 | # @api private 27 | def flay_report 28 | flay.report 29 | end 30 | 31 | private 32 | 33 | # Memoized flay instance 34 | # 35 | # @return [Flay] 36 | # 37 | # @api private 38 | def flay 39 | ::Flay.new(mass: minimum_mass).tap do |flay| 40 | flay.process(*files) 41 | flay.analyze 42 | end 43 | end 44 | memoize :flay, freezer: :noop 45 | end 46 | 47 | # Expand include and exclude file settings for flay 48 | class FileList 49 | include Procto.call, Concord.new(:includes, :excludes) 50 | 51 | GLOB = '**/*.{rb,erb}' 52 | 53 | # Expand includes and filter by excludes 54 | # 55 | # @return [Set] 56 | # 57 | # @api private 58 | def call 59 | include_set - exclude_set 60 | end 61 | 62 | private 63 | 64 | # Set of excluded files 65 | # 66 | # @return [Set] 67 | # 68 | # @api private 69 | def exclude_set 70 | excludes.flat_map(&Pathname.method(:glob)) 71 | end 72 | 73 | # Set of included files 74 | # 75 | # Expanded using flay's file expander which takes into 76 | # account flay's plugin support 77 | # 78 | # @return [Set] 79 | # 80 | # @api private 81 | def include_set 82 | Set.new(flay_includes.map(&method(:Pathname))) 83 | end 84 | 85 | # Expand includes using flay 86 | # 87 | # Expanded using flay's file expander which takes into 88 | # account flay's plugin support 89 | # 90 | # @return [Array] 91 | # 92 | # @api private 93 | def flay_includes 94 | PathExpander.new(includes.dup, GLOB).process 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /config/reek.yml: -------------------------------------------------------------------------------- 1 | --- 2 | detectors: 3 | Attribute: 4 | enabled: false 5 | exclude: [] 6 | BooleanParameter: 7 | enabled: true 8 | exclude: [] 9 | ClassVariable: 10 | enabled: true 11 | exclude: [] 12 | ControlParameter: 13 | enabled: true 14 | exclude: [] 15 | DataClump: 16 | enabled: true 17 | exclude: [] 18 | max_copies: 0 19 | min_clump_size: 2 20 | DuplicateMethodCall: 21 | enabled: true 22 | exclude: [] 23 | max_calls: 1 24 | allow_calls: [] 25 | FeatureEnvy: 26 | enabled: true 27 | exclude: [] 28 | IrresponsibleModule: 29 | enabled: true 30 | exclude: [] 31 | LongParameterList: 32 | enabled: true 33 | exclude: 34 | - Devtools::Config#self.attribute 35 | max_params: 2 36 | overrides: {} 37 | LongYieldList: 38 | enabled: true 39 | exclude: [] 40 | max_params: 0 41 | NestedIterators: 42 | enabled: true 43 | exclude: [] 44 | max_allowed_nesting: 1 45 | ignore_iterators: [] 46 | NilCheck: 47 | enabled: true 48 | exclude: [] 49 | RepeatedConditional: 50 | enabled: true 51 | exclude: [] 52 | max_ifs: 1 53 | TooManyConstants: 54 | enabled: true 55 | exclude: 56 | - Devtools 57 | TooManyInstanceVariables: 58 | enabled: true 59 | exclude: [] 60 | max_instance_variables: 2 61 | TooManyMethods: 62 | enabled: true 63 | exclude: [] 64 | max_methods: 15 65 | TooManyStatements: 66 | enabled: true 67 | exclude: [] 68 | max_statements: 5 69 | UncommunicativeMethodName: 70 | enabled: true 71 | exclude: [] 72 | reject: 73 | - '/^[a-z]$/' 74 | - '/[0-9]$/' 75 | - '/[A-Z]/' 76 | accept: [] 77 | UncommunicativeModuleName: 78 | enabled: true 79 | exclude: [] 80 | reject: 81 | - '/^.$/' 82 | - '/[0-9]$/' 83 | accept: [] 84 | UncommunicativeParameterName: 85 | enabled: true 86 | exclude: [] 87 | reject: 88 | - '/^.$/' 89 | - '/[0-9]$/' 90 | - '/[A-Z]/' 91 | accept: [] 92 | UncommunicativeVariableName: 93 | enabled: true 94 | exclude: [] 95 | reject: 96 | - '/^.$/' 97 | - '/[0-9]$/' 98 | - '/[A-Z]/' 99 | accept: [] 100 | UnusedParameters: 101 | enabled: true 102 | exclude: [] 103 | UtilityFunction: 104 | enabled: true 105 | exclude: 106 | - Devtools::Project::Initializer::Rspec#require_files # intentional for deduplication 107 | -------------------------------------------------------------------------------- /config/rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: ../.rubocop.yml 2 | 3 | AllCops: 4 | TargetRubyVersion: 2.5.0 5 | 6 | Metrics/BlockLength: 7 | Exclude: 8 | # Ignore RSpec DSL 9 | - spec/**/* 10 | # Ignore gemspec DSL 11 | - '*.gemspec' 12 | 13 | Naming/FileName: 14 | Exclude: 15 | - Rakefile 16 | 17 | # Avoid parameter lists longer than five parameters. 18 | ParameterLists: 19 | Max: 3 20 | CountKeywordArgs: true 21 | 22 | # Avoid more than `Max` levels of nesting. 23 | BlockNesting: 24 | Max: 3 25 | 26 | # Align with the style guide. 27 | CollectionMethods: 28 | PreferredMethods: 29 | collect: 'map' 30 | inject: 'reduce' 31 | find: 'detect' 32 | find_all: 'select' 33 | 34 | # Do not force public/protected/private keyword to be indented at the same 35 | # level as the def keyword. My personal preference is to outdent these keywords 36 | # because I think when scanning code it makes it easier to identify the 37 | # sections of code and visually separate them. When the keyword is at the same 38 | # level I think it sort of blends in with the def keywords and makes it harder 39 | # to scan the code and see where the sections are. 40 | AccessModifierIndentation: 41 | Enabled: false 42 | 43 | # Limit line length 44 | LineLength: 45 | Max: 106 46 | 47 | # Disable documentation checking until a class needs to be documented once 48 | Documentation: 49 | Enabled: false 50 | 51 | # Do not always use &&/|| instead of and/or. 52 | AndOr: 53 | Enabled: false 54 | 55 | # Do not favor modifier if/unless usage when you have a single-line body 56 | IfUnlessModifier: 57 | Enabled: false 58 | 59 | # Allow case equality operator (in limited use within the specs) 60 | CaseEquality: 61 | Enabled: false 62 | 63 | # Constants do not always have to use SCREAMING_SNAKE_CASE 64 | ConstantName: 65 | Enabled: false 66 | 67 | # Not all trivial readers/writers can be defined with attr_* methods 68 | TrivialAccessors: 69 | Enabled: false 70 | 71 | # Allow empty lines around class body 72 | EmptyLinesAroundClassBody: 73 | Enabled: false 74 | 75 | # Allow empty lines around module body 76 | EmptyLinesAroundModuleBody: 77 | Enabled: false 78 | 79 | # Allow empty lines around block body 80 | EmptyLinesAroundBlockBody: 81 | Enabled: false 82 | 83 | # Allow multiple line operations to not require indentation 84 | MultilineOperationIndentation: 85 | Enabled: false 86 | 87 | # Prefer String#% over Kernel#sprintf 88 | FormatString: 89 | Enabled: false 90 | 91 | # Use square brackets for literal Array objects 92 | PercentLiteralDelimiters: 93 | PreferredDelimiters: 94 | '%': '{}' 95 | '%i': '[]' 96 | '%q': () 97 | '%Q': () 98 | '%r': '{}' 99 | '%s': () 100 | '%w': '[]' 101 | '%W': '[]' 102 | '%x': () 103 | 104 | # Align if/else blocks with the variable assignment 105 | EndAlignment: 106 | EnforcedStyleAlignWith: variable 107 | 108 | # Do not always align parameters when it is easier to read 109 | Layout/ParameterAlignment: 110 | Exclude: 111 | - spec/**/*_spec.rb 112 | 113 | # Prefer #kind_of? over #is_a? 114 | ClassCheck: 115 | EnforcedStyle: kind_of? 116 | 117 | # Do not prefer double quotes to be used when %q or %Q is more appropriate 118 | Style/RedundantPercentQ: 119 | Enabled: false 120 | 121 | # Allow a maximum ABC score 122 | Metrics/AbcSize: 123 | Max: 20.1 124 | 125 | # Do not prefer lambda.call(...) over lambda.(...) 126 | LambdaCall: 127 | Enabled: false 128 | 129 | # Allow additional spaces 130 | ExtraSpacing: 131 | Enabled: false 132 | 133 | # All objects can still be mutated if their eigenclass is patched 134 | RedundantFreeze: 135 | Enabled: false 136 | 137 | # Prefer using `fail` when raising and `raise` when reraising 138 | SignalException: 139 | EnforcedStyle: semantic 140 | 141 | Style/FrozenStringLiteralComment: 142 | Enabled: false 143 | 144 | Style/CommentedKeyword: 145 | Enabled: false 146 | 147 | Style/MixinGrouping: 148 | Enabled: false 149 | 150 | Layout/MultilineMethodCallIndentation: 151 | EnforcedStyle: indented 152 | -------------------------------------------------------------------------------- /lib/devtools/rake/flay.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Devtools 4 | module Rake 5 | # Flay metric runner 6 | class Flay 7 | include Anima.new(:threshold, :total_score, :lib_dirs, :excludes), 8 | Procto.call(:verify), 9 | Adamantium 10 | 11 | BELOW_THRESHOLD = 'Adjust flay threshold down to %d' 12 | TOTAL_MISMATCH = 'Flay total is now %d, but expected %d' 13 | ABOVE_THRESHOLD = '%d chunks have a duplicate mass > %d' 14 | 15 | # Verify code specified by `files` does not violate flay expectations 16 | # 17 | # @raise [SystemExit] if a violation is found 18 | # @return [undefined] otherwise 19 | # 20 | # 21 | # @api private 22 | def verify 23 | # Run flay first to ensure the max mass matches the threshold 24 | below_threshold_message if below_threshold? 25 | 26 | total_mismatch_message if total_mismatch? 27 | 28 | # Run flay a second time with the threshold set 29 | return unless above_threshold? 30 | 31 | restricted_flay_scale.flay_report 32 | above_threshold_message 33 | end 34 | 35 | private 36 | 37 | # List of files flay will analyze 38 | # 39 | # @return [Set] 40 | # 41 | # @api private 42 | def files 43 | Devtools::Flay::FileList.call(lib_dirs, excludes) 44 | end 45 | 46 | # Is there mass duplication which exceeds specified `threshold` 47 | # 48 | # @return [Boolean] 49 | # 50 | # @api private 51 | def above_threshold? 52 | restricted_mass_size.nonzero? 53 | end 54 | 55 | # Is the specified `threshold` greater than the largest flay mass 56 | # 57 | # @return [Boolean] 58 | # 59 | # @api private 60 | def below_threshold? 61 | threshold > largest_mass 62 | end 63 | 64 | # Is the expected mass total different from the actual mass total 65 | # 66 | # @return [Boolean] 67 | # 68 | # @api private 69 | def total_mismatch? 70 | !total_mass.equal?(total_score) 71 | end 72 | 73 | # Above threshold message 74 | # 75 | # @return [String] 76 | # 77 | # @api private 78 | def above_threshold_message 79 | format_values = { mass: restricted_mass_size, threshold: threshold } 80 | Devtools.notify_metric_violation( 81 | format(ABOVE_THRESHOLD, format_values) 82 | ) 83 | end 84 | 85 | # Below threshold message 86 | # 87 | # @return [String] 88 | # 89 | # @api private 90 | def below_threshold_message 91 | Devtools.notify_metric_violation( 92 | format(BELOW_THRESHOLD, mass: largest_mass) 93 | ) 94 | end 95 | 96 | # Total mismatch message 97 | # 98 | # @return [String] 99 | # 100 | # @api private 101 | def total_mismatch_message 102 | Devtools.notify_metric_violation( 103 | format(TOTAL_MISMATCH, mass: total_mass, expected: total_score) 104 | ) 105 | end 106 | 107 | # Size of mass measured by `Flay::Scale` and filtered by `threshold` 108 | # 109 | # @return [Integer] 110 | # 111 | # @api private 112 | def restricted_mass_size 113 | restricted_flay_scale.measure.size 114 | end 115 | 116 | # Sum of all flay mass 117 | # 118 | # @return [Integer] 119 | # 120 | # @api private 121 | def total_mass 122 | flay_masses.reduce(:+).to_i 123 | end 124 | 125 | # Largest flay mass found 126 | # 127 | # @return [Integer] 128 | # 129 | # @api private 130 | def largest_mass 131 | flay_masses.max.to_i 132 | end 133 | 134 | # Flay scale which only measures mass above `threshold` 135 | # 136 | # @return [Flay::Scale] 137 | # 138 | # @api private 139 | def restricted_flay_scale 140 | Devtools::Flay::Scale.new(minimum_mass: threshold.succ, files: files) 141 | end 142 | memoize :restricted_flay_scale 143 | 144 | # All flay masses found in `files` 145 | # 146 | # @return [Array] 147 | # 148 | # @api private 149 | def flay_masses 150 | Devtools::Flay::Scale.call(minimum_mass: 0, files: files) 151 | end 152 | memoize :flay_masses 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /lib/devtools/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Devtools 4 | # Abstract base class of tool configuration 5 | class Config 6 | include Adamantium::Flat, AbstractType, Concord.new(:config_dir) 7 | 8 | # Represent no configuration 9 | DEFAULT_CONFIG = {}.freeze 10 | 11 | # Simple named type check representation 12 | class TypeCheck 13 | # Type check against expected class 14 | include Concord.new(:name, :allowed_classes) 15 | 16 | ERROR_FORMAT = '%s: Got instance of %s expected %s' 17 | CLASS_DELIM = ',' 18 | 19 | # Check value for instance of expected class 20 | # 21 | # @param [Object] value 22 | # 23 | # @return [Object] 24 | def call(value) 25 | klass = value.class 26 | format_values = { 27 | name: name, 28 | got: klass, 29 | allowed: allowed_classes.join(CLASS_DELIM) 30 | } 31 | 32 | unless allowed_classes.any?(&klass.method(:equal?)) 33 | fail TypeError, format(ERROR_FORMAT, format_values) 34 | end 35 | 36 | value 37 | end 38 | end # TypeCheck 39 | 40 | private_constant(*constants(false)) 41 | 42 | # Error raised on type errors 43 | TypeError = Class.new(RuntimeError) 44 | 45 | # Declare an attribute 46 | # 47 | # @param [Symbol] name 48 | # @param [Array] classes 49 | # 50 | # @api private 51 | # 52 | # @return [self] 53 | # 54 | def self.attribute(name, classes, **options) 55 | default = [options.fetch(:default)] if options.key?(:default) 56 | type_check = TypeCheck.new(name, classes) 57 | key = name.to_s 58 | 59 | define_method(name) do 60 | type_check.call(raw.fetch(key, *default)) 61 | end 62 | end 63 | private_class_method :attribute 64 | 65 | # Return config path 66 | # 67 | # @return [String] 68 | # 69 | # @api private 70 | # 71 | def config_file 72 | config_dir.join(self.class::FILE) 73 | end 74 | memoize :config_file 75 | 76 | private 77 | 78 | # Return raw data 79 | # 80 | # @return [Hash] 81 | # 82 | # @api private 83 | # 84 | def raw 85 | yaml_config || DEFAULT_CONFIG 86 | end 87 | memoize :raw 88 | 89 | # Return the raw config data from a yaml file 90 | # 91 | # @return [Hash] 92 | # returned if the yaml file is found 93 | # @return [nil] 94 | # returned if the yaml file is not found 95 | # 96 | # @api private 97 | # 98 | def yaml_config 99 | IceNine.deep_freeze(YAML.load_file(config_file)) if config_file.file? 100 | end 101 | 102 | # Rubocop configuration 103 | class Rubocop < self 104 | FILE = 'rubocop.yml' 105 | end # Rubocop 106 | 107 | # Reek configuration 108 | class Reek < self 109 | FILE = 'reek.yml' 110 | end # Reek 111 | 112 | # Flay configuration 113 | # 114 | class Flay < self 115 | FILE = 'flay.yml' 116 | DEFAULT_LIB_DIRS = %w[lib].freeze 117 | DEFAULT_EXCLUDES = %w[].freeze 118 | 119 | attribute :total_score, [0.class] 120 | attribute :threshold, [0.class] 121 | attribute :lib_dirs, [Array], default: DEFAULT_LIB_DIRS 122 | attribute :excludes, [Array], default: DEFAULT_EXCLUDES 123 | end # Flay 124 | 125 | # Yardstick configuration 126 | class Yardstick < self 127 | FILE = 'yardstick.yml' 128 | OPTIONS = %w[ 129 | threshold 130 | rules 131 | verbose 132 | path 133 | require_exact_threshold 134 | ].freeze 135 | 136 | # Options hash that Yardstick understands 137 | # 138 | # @return [Hash] 139 | # 140 | # @api private 141 | def options 142 | OPTIONS.each_with_object({}) do |name, hash| 143 | hash[name] = raw.fetch(name, nil) 144 | end 145 | end 146 | end # Yardstick 147 | 148 | # Flog configuration 149 | class Flog < self 150 | FILE = 'flog.yml' 151 | DEFAULT_LIB_DIRS = %w[lib].freeze 152 | 153 | attribute :total_score, [Float] 154 | attribute :threshold, [Float] 155 | attribute :lib_dirs, [Array], default: DEFAULT_LIB_DIRS 156 | end # Flog 157 | 158 | # Devtools configuration 159 | class Devtools < self 160 | FILE = 'devtools.yml' 161 | DEFAULT_UNIT_TEST_TIMEOUT = 0.1 # 100ms 162 | 163 | attribute :unit_test_timeout, [Float], default: DEFAULT_UNIT_TEST_TIMEOUT 164 | end # Devtools 165 | end # Config 166 | end # Devtools 167 | -------------------------------------------------------------------------------- /spec/integration/devtools/rake/flay/verify_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Devtools::Rake::Flay, '#verify' do 4 | let(:tempfile) { Tempfile.new(%w[file .rb], Dir.mktmpdir) } 5 | let(:file) { Pathname(tempfile.path) } 6 | let(:directories) { [file.dirname.to_s] } 7 | 8 | let(:ruby) do 9 | <<-ERUBY 10 | def foo; end 11 | def bar; end 12 | ERUBY 13 | end 14 | 15 | around(:each) do |example| 16 | begin 17 | # silence other flay output 18 | $stdout = $stderr = StringIO.new 19 | 20 | tempfile.write(ruby) 21 | tempfile.close 22 | 23 | example.run 24 | ensure 25 | $stdout = STDOUT 26 | $stderr = STDERR 27 | 28 | file.unlink 29 | end 30 | end 31 | 32 | context 'reporting' do 33 | let(:options) do 34 | { threshold: 3, total_score: 3, lib_dirs: directories, excludes: [] } 35 | end 36 | 37 | let(:instance) { described_class.new(options) } 38 | 39 | it 'measures total mass' do 40 | allow(::Flay).to receive(:new).and_call_original 41 | 42 | instance.verify 43 | 44 | expect(::Flay).to have_received(:new).with(hash_including(mass: 0)) 45 | end 46 | 47 | it 'does not report the files it is processing' do 48 | expect { instance.verify }.to_not output(/Processing #{file}/).to_stderr 49 | end 50 | end 51 | 52 | context 'when theshold is too low' do 53 | let(:instance) do 54 | described_class.new( 55 | threshold: 0, 56 | total_score: 0, 57 | lib_dirs: directories, 58 | excludes: [] 59 | ) 60 | end 61 | 62 | specify do 63 | expect { instance.verify } 64 | .to raise_error(SystemExit) 65 | .with_message('Flay total is now 3, but expected 0') 66 | end 67 | end 68 | 69 | context 'when threshold is too high' do 70 | let(:instance) do 71 | described_class.new( 72 | threshold: 1000, 73 | total_score: 0, 74 | lib_dirs: directories, 75 | excludes: [] 76 | ) 77 | end 78 | 79 | specify do 80 | expect { instance.verify } 81 | .to raise_error(SystemExit) 82 | .with_message('Adjust flay threshold down to 3') 83 | end 84 | end 85 | 86 | context 'when total is too high' do 87 | let(:instance) do 88 | described_class.new( 89 | threshold: 3, 90 | total_score: 50, 91 | lib_dirs: directories, 92 | excludes: [] 93 | ) 94 | end 95 | 96 | specify do 97 | expect { instance.verify } 98 | .to raise_error(SystemExit) 99 | .with_message('Flay total is now 3, but expected 50') 100 | end 101 | end 102 | 103 | context 'when duplicate mass is greater than 0' do 104 | let(:ruby) do 105 | <<-ERUBY 106 | def foo 107 | :hi if baz? 108 | end 109 | 110 | def bar 111 | :hi if baz? 112 | end 113 | ERUBY 114 | end 115 | 116 | let(:report) do 117 | <<~REPORT 118 | Total score (lower is better) = 10 119 | 120 | 1) Similar code found in :defn (mass = 10) 121 | #{file}:1 122 | #{file}:5 123 | REPORT 124 | end 125 | 126 | let(:instance) do 127 | described_class.new( 128 | threshold: 3, 129 | total_score: 5, 130 | lib_dirs: directories, 131 | excludes: [] 132 | ) 133 | end 134 | 135 | specify do 136 | expect { instance.verify } 137 | .to raise_error(SystemExit) 138 | .with_message('1 chunks have a duplicate mass > 3') 139 | end 140 | 141 | specify do 142 | expect { instance.verify } 143 | .to raise_error(SystemExit) 144 | .and output(report).to_stdout 145 | end 146 | end 147 | 148 | context 'when multiple duplicate masses' do 149 | let(:ruby) do 150 | <<-ERUBY 151 | def foo; end 152 | def bar; end 153 | 154 | class Foo 155 | def initialize 156 | @a = 1 157 | end 158 | end 159 | class Bar 160 | def initialize 161 | @a = 1 162 | end 163 | end 164 | ERUBY 165 | end 166 | 167 | let(:instance) do 168 | described_class.new( 169 | threshold: 5, 170 | total_score: 8, 171 | lib_dirs: directories, 172 | excludes: [] 173 | ) 174 | end 175 | 176 | it 'sums masses for total' do 177 | expect { instance.verify }.to_not raise_error 178 | end 179 | end 180 | 181 | context 'when no duplication masses' do 182 | let(:ruby) { '' } 183 | let(:instance) do 184 | described_class.new( 185 | threshold: 0, 186 | total_score: 0, 187 | lib_dirs: directories, 188 | excludes: [] 189 | ) 190 | end 191 | 192 | specify do 193 | expect { instance.verify }.to_not raise_error 194 | end 195 | end 196 | end 197 | --------------------------------------------------------------------------------