├── .rspec ├── .rubocop.yml ├── lib ├── churnalizer │ ├── version.rb │ ├── cli.rb │ └── analyzer.rb ├── churnalizer.rb ├── complexity_analyzers │ └── ruby.rb ├── churn_analyzers │ └── git.rb ├── file_scanners │ ├── ruby.rb │ └── ignorer.rb └── graph_builders │ ├── google_charts.rb │ └── views │ └── google_chart.html ├── screenshot.png ├── exe └── churnalizer ├── .travis.yml ├── Rakefile ├── bin ├── setup └── console ├── Gemfile ├── .gitignore ├── spec ├── churnalizer_spec.rb ├── spec_helper.rb └── file_scanners │ ├── ruby_spec.rb │ └── ignorer_spec.rb ├── Gemfile.lock ├── LICENCE ├── churnalizer.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Style/StringLiterals: 2 | EnforcedStyle: double_quotes 3 | -------------------------------------------------------------------------------- /lib/churnalizer/version.rb: -------------------------------------------------------------------------------- 1 | module Churnalizer 2 | VERSION = "0.1.3" 3 | end 4 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gosukiwi/churnalizer/HEAD/screenshot.png -------------------------------------------------------------------------------- /exe/churnalizer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "churnalizer/cli" 4 | Churnalizer::CLI.new 5 | -------------------------------------------------------------------------------- /lib/churnalizer.rb: -------------------------------------------------------------------------------- 1 | require "churnalizer/analyzer" 2 | 3 | module Churnalizer 4 | # Silence is beautiful 5 | end 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.2.0 5 | before_install: gem install bundler -v 1.16.1 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in churnalizer.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | *.swp 13 | .DS_Store 14 | .byebug_history 15 | chart.html 16 | -------------------------------------------------------------------------------- /spec/churnalizer_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Churnalizer do 2 | it "has a version number" do 3 | expect(Churnalizer::VERSION).not_to be nil 4 | end 5 | 6 | it "does something useful" do 7 | pending "Aham" 8 | #analyzer = Churnalizer::Analyzer.new "foo" 9 | #expect(analyzer.run).to eq(true) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "churnalizer" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /lib/complexity_analyzers/ruby.rb: -------------------------------------------------------------------------------- 1 | require "flog" 2 | 3 | module ComplexityAnalyzers 4 | # This class uses Flog to check the 5 | # complexity of a Ruby file. 6 | # 7 | class Ruby 8 | def analyze(file) 9 | flog.reset 10 | flog.flog(file) 11 | flog.total_score 12 | end 13 | 14 | def flog 15 | @flog ||= Flog.new 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "churnalizer" 3 | require "byebug" 4 | 5 | RSpec.configure do |config| 6 | # Enable flags like --only-failures and --next-failure 7 | config.example_status_persistence_file_path = ".rspec_status" 8 | 9 | # Disable RSpec exposing methods globally on `Module` and `main` 10 | config.disable_monkey_patching! 11 | 12 | config.expect_with :rspec do |c| 13 | c.syntax = :expect 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/churn_analyzers/git.rb: -------------------------------------------------------------------------------- 1 | module ChurnAnalyzers 2 | # This class uses Git to analyze the churn (how many times it was changed) of 3 | # a file. 4 | # 5 | class Git 6 | def analyze(file) 7 | as_integer run_command(file) 8 | end 9 | 10 | private 11 | 12 | # Trim string and cast to integer 13 | def as_integer(string) 14 | string.gsub(/[\n ]+/, "").to_i 15 | end 16 | 17 | # NOTE: This only works on *NIX systems 18 | def run_command(file) 19 | `cd $(dirname #{file}) && git log --oneline -- #{file} | wc -l` 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/file_scanners/ruby.rb: -------------------------------------------------------------------------------- 1 | require "file_scanners/ignorer" 2 | 3 | module FileScanners 4 | # FileScanners are in charge of finding files to be analyzed. This one in 5 | # particular finds Ruby files. 6 | # 7 | class Ruby 8 | attr_reader :path 9 | def initialize(path) 10 | @path = path 11 | end 12 | 13 | def scan 14 | files.reject do |file| 15 | ignore? file 16 | end 17 | end 18 | 19 | private 20 | 21 | def files 22 | Dir.glob("#{path}/**/*.rb") 23 | end 24 | 25 | def ignorer 26 | @ignorer ||= Ignorer.new(path) 27 | end 28 | 29 | def ignore?(file) 30 | ignorer.ignore? file 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/file_scanners/ruby_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe FileScanners::Ruby do 2 | it "uses glob to find files" do 3 | scanner = FileScanners::Ruby.new("foo") 4 | allow(Dir).to receive(:glob).and_return([]) 5 | 6 | scanner.scan 7 | 8 | expect(Dir).to have_received(:glob).with("foo/**/*.rb") 9 | end 10 | 11 | it "uses an ignorer" do 12 | scanner = FileScanners::Ruby.new("foo") 13 | ignorer = spy 14 | allow(scanner).to receive(:files).and_return(["foo.rb", "bar.rb"]) 15 | allow(scanner).to receive(:ignorer).and_return(ignorer) 16 | allow(ignorer).to receive(:ignore?).and_return(true) 17 | 18 | scanner.scan 19 | 20 | expect(ignorer).to have_received(:ignore?).with("foo.rb") 21 | expect(ignorer).to have_received(:ignore?).with("bar.rb") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/graph_builders/google_charts.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | module GraphBuilders 4 | # Generate an HTML chart using Google Charts 5 | # 6 | class GoogleCharts 7 | def build(graph_data, save_to:) 8 | write_chart graph_data, to_file: save_to 9 | open_with_default_browser save_to 10 | File.expand_path save_to 11 | end 12 | 13 | private 14 | 15 | def open_with_default_browser(file) 16 | `open #{file}` if `which open` == "/usr/bin/open\n" 17 | end 18 | 19 | def write_chart(graph_data, to_file:) 20 | contents = compile_template graph_data 21 | File.write(to_file, contents) 22 | end 23 | 24 | def compile_template(graph_data) 25 | template.gsub("{{graph_data}}", graph_data.to_json) 26 | end 27 | 28 | def template 29 | File.read view_path("google_chart.html") 30 | end 31 | 32 | def view_path(name) 33 | "#{__dir__}/views/#{name}" 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/churnalizer/cli.rb: -------------------------------------------------------------------------------- 1 | require "churnalizer/analyzer" 2 | 3 | module Churnalizer 4 | class CLI 5 | def initialize 6 | puts parse_action 7 | end 8 | 9 | private 10 | 11 | def analyze(path) 12 | churnalizer = Churnalizer::Analyzer.new(path) 13 | churnalizer.run 14 | end 15 | 16 | def parse_action 17 | case action 18 | when "help", "" 19 | help 20 | when "version" 21 | version 22 | else 23 | analyze action 24 | end 25 | end 26 | 27 | def help 28 | """This is Churnalizer, a churn vs complexity analyzer for your Ruby 29 | application. 30 | 31 | Usage: 32 | churnalizer my-app/ 33 | 34 | churnalizer help 35 | displays this dialog 36 | 37 | churnalizer version 38 | displays current version 39 | """ 40 | end 41 | 42 | def version 43 | Churnalizer::VERSION 44 | end 45 | 46 | def action 47 | ARGV[0].to_s 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | churnalizer (0.1.3) 5 | flog 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | byebug (10.0.0) 11 | diff-lcs (1.3) 12 | flog (4.6.2) 13 | path_expander (~> 1.0) 14 | ruby_parser (~> 3.1, > 3.1.0) 15 | sexp_processor (~> 4.8) 16 | path_expander (1.0.2) 17 | rake (13.0.1) 18 | rb-readline (0.5.5) 19 | rspec (3.7.0) 20 | rspec-core (~> 3.7.0) 21 | rspec-expectations (~> 3.7.0) 22 | rspec-mocks (~> 3.7.0) 23 | rspec-core (3.7.1) 24 | rspec-support (~> 3.7.0) 25 | rspec-expectations (3.7.0) 26 | diff-lcs (>= 1.2.0, < 2.0) 27 | rspec-support (~> 3.7.0) 28 | rspec-mocks (3.7.0) 29 | diff-lcs (>= 1.2.0, < 2.0) 30 | rspec-support (~> 3.7.0) 31 | rspec-support (3.7.1) 32 | ruby_parser (3.11.0) 33 | sexp_processor (~> 4.9) 34 | sexp_processor (4.10.1) 35 | 36 | PLATFORMS 37 | ruby 38 | 39 | DEPENDENCIES 40 | bundler (~> 1.16) 41 | byebug 42 | churnalizer! 43 | rake (~> 13.0) 44 | rb-readline 45 | rspec (~> 3.0) 46 | 47 | BUNDLED WITH 48 | 1.16.1 49 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Federico Ramirez 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 | -------------------------------------------------------------------------------- /lib/file_scanners/ignorer.rb: -------------------------------------------------------------------------------- 1 | module FileScanners 2 | # Takes a list of files and ignores them appropriately 3 | # 4 | class Ignorer 5 | DEFAULT_IGNORES = %w[/test/ /spec/ /db/ /config/ /bin/ /vendor/ /public/].freeze 6 | 7 | attr_reader :path 8 | def initialize(path) 9 | @path = path 10 | end 11 | 12 | def ignore?(file) 13 | ignore_rules.each do |rule| 14 | return true if rule === file 15 | end 16 | false 17 | end 18 | 19 | private 20 | 21 | def ignore_rules 22 | @ignore_rules ||= churnignore.map { |regex| Regexp.new(regex) } 23 | end 24 | 25 | def churnignore 26 | if churnignore_contents.empty? 27 | DEFAULT_IGNORES 28 | else 29 | churnignore_as_array 30 | end 31 | end 32 | 33 | def churnignore_as_array 34 | churnignore_contents.split("\n").compact 35 | end 36 | 37 | def churnignore_contents 38 | @churnignore_contents ||= File.read(churnignore_path) 39 | rescue Errno::ENOENT 40 | "" 41 | end 42 | 43 | def churnignore_path 44 | "#{path}/.churnignore" 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /churnalizer.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "churnalizer/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "churnalizer" 8 | spec.version = Churnalizer::VERSION 9 | spec.authors = ["Federico Ramirez"] 10 | spec.email = ["federico_r@beezwax.net"] 11 | 12 | spec.summary = %q{Analyze your Ruby application for Churn vs Complexity} 13 | spec.homepage = "http://github.com/gosukiwi/churnalizer" 14 | 15 | if spec.respond_to?(:metadata) 16 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 17 | else 18 | raise "RubyGems 2.0 or newer is required to protect against " \ 19 | "public gem pushes." 20 | end 21 | 22 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 23 | f.match(%r{^(test|spec|features)/}) 24 | end 25 | spec.bindir = "exe" 26 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 27 | spec.require_paths = ["lib"] 28 | 29 | spec.add_development_dependency "bundler", "~> 1.16" 30 | spec.add_development_dependency "rake", "~> 13.0" 31 | spec.add_development_dependency "rspec", "~> 3.0" 32 | spec.add_development_dependency "byebug" 33 | spec.add_development_dependency "rb-readline" 34 | 35 | spec.add_dependency "flog" 36 | end 37 | -------------------------------------------------------------------------------- /spec/file_scanners/ignorer_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe FileScanners::Ignorer do 2 | it "works with default ignores" do 3 | ignorer = FileScanners::Ignorer.new("dummy_path") 4 | 5 | expect(ignorer.ignore?("/spec/foo.rb")).to eq true 6 | expect(ignorer.ignore?("/test/foo.rb")).to eq true 7 | expect(ignorer.ignore?("/db/foo.rb")).to eq true 8 | expect(ignorer.ignore?("/config/foo.rb")).to eq true 9 | expect(ignorer.ignore?("/bin/foo.rb")).to eq true 10 | expect(ignorer.ignore?("/vendor/foo.rb")).to eq true 11 | expect(ignorer.ignore?("/public/foo.rb")).to eq true 12 | expect(ignorer.ignore?("/bar/foo.rb")).to eq false 13 | end 14 | 15 | it "works with custom churnignore" do 16 | ignorer = FileScanners::Ignorer.new("dummy_path") 17 | allow(ignorer).to receive(:churnignore_contents).and_return("/foo/") 18 | 19 | expect(ignorer.ignore?("/foo/bar.rb")).to eq true 20 | expect(ignorer.ignore?("/bar/bar.rb")).to eq false 21 | end 22 | 23 | it "reads churnignore from file" do 24 | Dir.mkdir "demo" 25 | File.write "demo/.churnignore", "/foo/\n/bar/\n" 26 | ignorer = FileScanners::Ignorer.new("demo") 27 | 28 | expect(ignorer.ignore?("/foo/bar.rb")).to eq true 29 | expect(ignorer.ignore?("/bar/bar.rb")).to eq true 30 | expect(ignorer.ignore?("baz.rb")).to eq false 31 | 32 | File.delete "demo/.churnignore" 33 | Dir.rmdir "demo" 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/churnalizer/analyzer.rb: -------------------------------------------------------------------------------- 1 | require "file_scanners/ruby" 2 | require "churn_analyzers/git" 3 | require "complexity_analyzers/ruby" 4 | require "graph_builders/google_charts" 5 | require "churnalizer/version" 6 | 7 | module Churnalizer 8 | class Analyzer 9 | attr_reader :path 10 | def initialize(path) 11 | @path = File.expand_path path 12 | end 13 | 14 | def run 15 | build_graph analyzed_files 16 | end 17 | 18 | private 19 | 20 | def analyzed_files 21 | files.map do |file| 22 | [display_name_for(file), analyze(file)] 23 | end.to_h 24 | end 25 | 26 | def display_name_for(file) 27 | file.gsub(path, ".") 28 | end 29 | 30 | def analyze(file) 31 | { churn: churn_for(file), complexity: complexity_for(file) } 32 | end 33 | 34 | def files 35 | file_scanner.scan 36 | end 37 | 38 | def file_scanner 39 | @file_scanner ||= FileScanners::Ruby.new(path) 40 | end 41 | 42 | def churn_analyzer 43 | @churn_analyzer ||= ChurnAnalyzers::Git.new 44 | end 45 | 46 | def churn_for(file) 47 | churn_analyzer.analyze(file) 48 | end 49 | 50 | def complexity_analyzer 51 | @complexity_analyzer ||= ComplexityAnalyzers::Ruby.new 52 | end 53 | 54 | def complexity_for(file) 55 | complexity_analyzer.analyze(file) 56 | end 57 | 58 | def graph_builder 59 | @graph_builder ||= GraphBuilders::GoogleCharts.new 60 | end 61 | 62 | def build_graph(graph_data) 63 | graph_builder.build(graph_data, save_to: "chart.html") 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Churnalizer 2 | 3 | Churnalizer helps you analyze the churn vs complexity of your Ruby application. 4 | 5 | ![Graph Screenshot](screenshot.png?raw=true) 6 | 7 | What is churn vs complexity? I first learnt about it in Sandi Metz's blog post 8 | [Breaking up the 9 | Behemot](https://www.sandimetz.com/blog/2017/9/13/breaking-up-the-behemoth), 10 | there she links to [another great 11 | article](https://www.stickyminds.com/article/getting-empirical-about-refactoring) 12 | by Michael Feathers. 13 | 14 | Basically, it shows you which files need to be refactored first -- top-right 15 | corner of the graph. Churn is how many times a file has been changed, so you 16 | want files which change a lot to be simple. Files which are never touched are 17 | fine with being complex for a while. 18 | 19 | ## Installation 20 | 21 | $ gem install churnalizer 22 | 23 | ## Usage 24 | 25 | $ churnalizer my-app-directory/ 26 | 27 | This was only tested on MacOS. It uses the `open` command to make things easier, 28 | so when the gem is done analyzing your app, it will open the generated chart 29 | with your default browser. 30 | 31 | That functionality would not work on Linux so the chart would need to be opened 32 | manually. 33 | 34 | Don't think it works on Windows at all, given the churn counter uses the 35 | following command: `cd $(dirname #{file}) && git log --oneline -- #{file} | wc -l` 36 | 37 | ### Ignoring Files 38 | 39 | By default, Churnalizer will ignore specs and Rails files like `schema.rb` and 40 | `routes.rb`. 41 | 42 | For custom ignore rules, in the base directory you are analyzing, create a file 43 | named `.churnignore`, in that file, add a regular expression per-line to run 44 | against file paths. If a regex returns true, it will be ignored. 45 | 46 | For example, this is the default `.churnignore` file: 47 | 48 | /test/ 49 | /spec/ 50 | /db/ 51 | /config/ 52 | /bin/ 53 | /vendor/ 54 | /public/ 55 | 56 | ## Development 57 | 58 | After checking out the repo, run `bin/setup` to install dependencies. Then, run 59 | `rake spec` to run the tests. You can also run `bin/console` for an interactive 60 | prompt that will allow you to experiment. 61 | 62 | To install this gem onto your local machine, run `bundle exec rake install`. To 63 | release a new version, update the version number in `version.rb`, and then run 64 | `bundle exec rake release`, which will create a git tag for the version, push 65 | git commits and tags, and push the `.gem` file to 66 | [rubygems.org](https://rubygems.org). 67 | 68 | ### Playing with the CLI 69 | 70 | To locally run the CLI use `ruby -Ilib exe/churnalizer` 71 | 72 | ## Contributing 73 | 74 | Bug reports and pull requests are welcome on GitHub at 75 | https://github.com/gosukiwi/churnalizer. 76 | -------------------------------------------------------------------------------- /lib/graph_builders/views/google_chart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Churnalizer: Churn vs Complexity 6 | 40 | 41 | 42 |

Churn vs Complexity

43 | 44 |
45 | 46 |

How to read this chart?

47 |

48 | Basically, you want to minimize the amount of files which show up at the 49 | top-right of the chart. Those are files which are complex and also change 50 | a lot. 51 |

52 |

53 | Ideally, you don't want to have complex files, but if you do, at least make 54 | sure they don't change often. The files at the top-right are good candidates 55 | for refactoring. 56 |

57 | 58 | See on GitHub 59 | 60 | 61 | 94 | 95 | 96 | --------------------------------------------------------------------------------