├── VERSION ├── .ruby-version ├── .gitignore ├── .dockerignore ├── config ├── eslint │ ├── .eslintignore │ └── .eslintrc ├── csslint │ └── .csslintrc ├── coffeelint │ └── coffeelint.json └── engines.yml ├── .ruby-style.yml ├── lib ├── file_utils_ext.rb └── cc │ ├── cli │ ├── console.rb │ ├── version.rb │ ├── engines │ │ ├── list.rb │ │ ├── remove.rb │ │ ├── disable.rb │ │ ├── enable.rb │ │ ├── engine_command.rb │ │ └── install.rb │ ├── engines.rb │ ├── help.rb │ ├── config.rb │ ├── upgrade_config_generator.rb │ ├── runner.rb │ ├── command.rb │ ├── config_generator.rb │ ├── validate_config.rb │ ├── analyze.rb │ ├── init.rb │ └── test.rb │ ├── analyzer │ ├── container_listener.rb │ ├── logging_container_listener.rb │ ├── composite_container_listener.rb │ ├── path_entries.rb │ ├── formatters.rb │ ├── engine_output.rb │ ├── formatters │ │ ├── spinner.rb │ │ ├── formatter.rb │ │ ├── json_formatter.rb │ │ └── plain_text_formatter.rb │ ├── engine_registry.rb │ ├── issue_sorter.rb │ ├── engine_output_filter.rb │ ├── raising_container_listener.rb │ ├── source_buffer.rb │ ├── statsd_container_listener.rb │ ├── location_description.rb │ ├── path_filter.rb │ ├── issue.rb │ ├── path_patterns.rb │ ├── filesystem.rb │ ├── engines_runner.rb │ ├── include_paths_builder.rb │ ├── path_minimizer.rb │ ├── engines_config_builder.rb │ ├── engine.rb │ ├── config.rb │ └── container.rb │ ├── cli.rb │ └── analyzer.rb ├── .codeclimate.yml ├── Rakefile ├── bin ├── codeclimate-init ├── codeclimate ├── check ├── release └── prep-release ├── spec ├── support │ ├── test_formatter.rb │ ├── test_container.rb │ ├── proc_helpers.rb │ ├── test_container_listener.rb │ ├── file_system_helpers.rb │ └── factory.rb ├── cc │ ├── analyzer_spec.rb │ ├── analyzer │ │ ├── engine_output_spec.rb │ │ ├── source_buffer_spec.rb │ │ ├── engine_registry_spec.rb │ │ ├── path_entries_spec.rb │ │ ├── logging_container_listener_spec.rb │ │ ├── raising_container_listener_spec.rb │ │ ├── issue_sorter_spec.rb │ │ ├── formatters │ │ │ ├── plain_text_formatter_spec.rb │ │ │ └── json_formatter_spec.rb │ │ ├── composite_container_listener_spec.rb │ │ ├── location_description_spec.rb │ │ ├── path_minimizer_spec.rb │ │ ├── path_patterns_spec.rb │ │ ├── statsd_container_listener_spec.rb │ │ ├── engine_output_filter_spec.rb │ │ ├── engines_runner_spec.rb │ │ ├── filesystem_spec.rb │ │ ├── config_spec.rb │ │ ├── issue_spec.rb │ │ ├── engine_spec.rb │ │ ├── engines_config_builder_spec.rb │ │ └── include_paths_builder_spec.rb │ └── cli │ │ ├── engines │ │ ├── list_spec.rb │ │ ├── install_spec.rb │ │ ├── remove_spec.rb │ │ ├── disable_spec.rb │ │ └── enable_spec.rb │ │ ├── command_spec.rb │ │ ├── runner_spec.rb │ │ ├── config_spec.rb │ │ ├── upgrade_config_generator_spec.rb │ │ ├── config_generator_spec.rb │ │ ├── analyze_spec.rb │ │ ├── validate_config_spec.rb │ │ └── init_spec.rb └── spec_helper.rb ├── Gemfile ├── WERE_HIRING.md ├── Makefile ├── Dockerfile ├── DEVELOPERS.md ├── circle.yml ├── LICENSE ├── codeclimate.gemspec ├── codeclimate-wrapper ├── Gemfile.lock ├── .rubocop.yml └── README.md /VERSION: -------------------------------------------------------------------------------- 1 | 0.16.4 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.2.2 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | coverage 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .bundle 3 | -------------------------------------------------------------------------------- /config/eslint/.eslintignore: -------------------------------------------------------------------------------- 1 | **/*{.,-}min.js 2 | -------------------------------------------------------------------------------- /.ruby-style.yml: -------------------------------------------------------------------------------- 1 | Style/TrailingBlankLines: 2 | Enabled: false 3 | -------------------------------------------------------------------------------- /config/csslint/.csslintrc: -------------------------------------------------------------------------------- 1 | --exclude-exts=.min.css 2 | --ignore=adjoining-classes,box-model,ids,order-alphabetical,unqualified-attributes 3 | -------------------------------------------------------------------------------- /lib/file_utils_ext.rb: -------------------------------------------------------------------------------- 1 | require "fileutils" 2 | 3 | module FileUtils 4 | def self.readable_by_all?(path) 5 | (File.stat(path).mode & 004) != 0 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | rubocop: 4 | enabled: true 5 | ratings: 6 | paths: 7 | - "**.rb" 8 | exclude_paths: 9 | - .bundle/**/* 10 | - spec/**/* 11 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rake/testtask" 2 | require "bundler/gem_tasks" 3 | 4 | Rake::TestTask.new do |t| 5 | t.test_files = Dir.glob("spec/**/*_spec.rb") 6 | t.libs = %w[lib spec] 7 | end 8 | 9 | task(default: :test) 10 | -------------------------------------------------------------------------------- /bin/codeclimate-init: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $LOAD_PATH.unshift(File.expand_path(File.join(File.dirname(__FILE__), "../lib"))) 3 | 4 | require "cc/cli" 5 | 6 | ENV["FILESYSTEM_DIR"] ||= "." 7 | 8 | CC::CLI::Init.new(ARGV).execute 9 | -------------------------------------------------------------------------------- /lib/cc/cli/console.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module CLI 3 | class Console < Command 4 | def run 5 | require "pry" 6 | binding.pry(quiet: true, prompt: Pry::SIMPLE_PROMPT, output: $stdout) 7 | end 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /bin/codeclimate: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $LOAD_PATH.unshift(File.expand_path(File.join(File.dirname(__FILE__), "../lib"))) 3 | 4 | require "cc/cli" 5 | 6 | ENV["FILESYSTEM_DIR"] ||= "." 7 | ENV["CODE_PATH"] ||= ENV["PWD"] 8 | 9 | CC::CLI::Runner.run(ARGV) 10 | -------------------------------------------------------------------------------- /spec/support/test_formatter.rb: -------------------------------------------------------------------------------- 1 | class TestFormatter 2 | attr_accessor :string 3 | 4 | def initialize 5 | @string = "" 6 | end 7 | 8 | def write(data) 9 | string << data 10 | end 11 | 12 | def failed(output) 13 | output 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :test do 6 | gem "mocha" 7 | gem "minitest" 8 | gem "minitest-around" 9 | gem "minitest-reporters" 10 | gem "rack-test" 11 | gem "rake" 12 | gem "codeclimate-test-reporter", require: nil 13 | end 14 | -------------------------------------------------------------------------------- /WERE_HIRING.md: -------------------------------------------------------------------------------- 1 | # Code Climate is Hiring 2 | 3 | Thanks for checking our CLI. Since you found your way here, you may be interested in working on open source, and building awesome tools for developers. If so, you should check out our open jobs: 4 | 5 | #### http://jobs.codeclimate.com/ 6 | -------------------------------------------------------------------------------- /lib/cc/analyzer/container_listener.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | class ContainerListener 4 | def started(_data) 5 | end 6 | 7 | def timed_out(_data) 8 | end 9 | 10 | def finished(_data) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/cc/analyzer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CC::Analyzer do 4 | it "can be configured with a statsd" do 5 | statsd = Object.new 6 | CC::Analyzer.statsd = statsd 7 | CC::Analyzer.statsd.must_equal statsd 8 | CC::Analyzer.statsd = CC::Analyzer::DummyStatsd.new 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/test_container.rb: -------------------------------------------------------------------------------- 1 | class TestContainer 2 | def initialize(outputs) 3 | @outputs = outputs 4 | @on_output = ->(*) { } 5 | end 6 | 7 | def on_output(*, &block) 8 | @on_output = block 9 | end 10 | 11 | def run(*) 12 | @outputs.each { |output| @on_output.call(output) } 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/cc/cli/version.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module CLI 3 | class Version < Command 4 | def run 5 | say version 6 | end 7 | 8 | private 9 | 10 | def version 11 | path = File.expand_path("../../../../VERSION", __FILE__) 12 | @version ||= File.read(path) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/cc/analyzer/engine_output_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::Analyzer 4 | describe EngineOutput do 5 | describe "#issue?" do 6 | it "returns true if the output is an issue" do 7 | output = { type: "issue" }.to_json 8 | 9 | EngineOutput.new(output).issue?.must_equal(true) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/proc_helpers.rb: -------------------------------------------------------------------------------- 1 | require "cc/cli/command" 2 | 3 | module ProcHelpers 4 | def capture_io_and_exit_code 5 | exit_code = 0 6 | 7 | stdout, stderr = capture_io do 8 | begin 9 | yield 10 | rescue SystemExit => ex 11 | exit_code = ex.status 12 | end 13 | end 14 | 15 | return stdout, stderr, exit_code 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/cc/cli/engines/list.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module CLI 3 | module Engines 4 | class List < EngineCommand 5 | def run 6 | say "Available engines:" 7 | engine_registry_list.sort_by { |name, _| name }.each do |name, attributes| 8 | say "- #{name}: #{attributes['description']}" 9 | end 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/cc/analyzer/source_buffer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CC::Analyzer::SourceBuffer do 4 | describe "#decompose_position" do 5 | it "extracts the line and column" do 6 | buffer = CC::Analyzer::SourceBuffer.new("foo.rb", "foo\nbar") 7 | line, column = buffer.decompose_position(5) 8 | line.must_equal 2 9 | column.must_equal 1 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/cc/cli/engines.rb: -------------------------------------------------------------------------------- 1 | require "cc/analyzer" 2 | 3 | module CC 4 | module CLI 5 | module Engines 6 | autoload :Disable, "cc/cli/engines/disable" 7 | autoload :Enable, "cc/cli/engines/enable" 8 | autoload :EngineCommand, "cc/cli/engines/engine_command" 9 | autoload :Install, "cc/cli/engines/install" 10 | autoload :List, "cc/cli/engines/list" 11 | autoload :Remove, "cc/cli/engines/remove" 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/cc/cli/engines/list_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::CLI::Engines 4 | describe List do 5 | describe "#run" do 6 | it "lists all engines in the config" do 7 | stdout, stderr = capture_io do 8 | List.new.run 9 | end 10 | 11 | engines = YAML.safe_load_file("config/engines.yml") 12 | 13 | engines.each do |name, engine| 14 | stdout.must_match(name) 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build install uninstall 2 | 3 | PREFIX ?= /usr/local 4 | 5 | image: 6 | docker build -t codeclimate/codeclimate . 7 | 8 | install: 9 | bin/check 10 | docker pull codeclimate/codeclimate:latest 11 | docker images | awk '/codeclimate\/codeclimate-/ { print $$1 }' | xargs -n1 docker pull || true 12 | mkdir -p $(DESTDIR)$(PREFIX)/bin 13 | install -m 0755 codeclimate-wrapper $(DESTDIR)$(PREFIX)/bin/codeclimate 14 | 15 | uninstall: 16 | $(RM) $(DESTDIR)$(PREFIX)/bin/codeclimate 17 | docker rmi codeclimate/codeclimate:latest 18 | 19 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "codeclimate-test-reporter" 2 | CodeClimate::TestReporter.start 3 | 4 | require "minitest/spec" 5 | require "minitest/autorun" 6 | require "minitest/reporters" 7 | require "minitest/around/spec" 8 | require "mocha/mini_test" 9 | require "safe_yaml" 10 | require "cc/cli" 11 | require "cc/yaml" 12 | 13 | Dir.glob("spec/support/**/*.rb").each(&method(:load)) 14 | 15 | SafeYAML::OPTIONS[:default_mode] = :safe 16 | 17 | Minitest::Reporters.use! Minitest::Reporters::DefaultReporter.new 18 | 19 | ENV["FILESYSTEM_DIR"] = "." 20 | -------------------------------------------------------------------------------- /spec/cc/cli/command_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::CLI 4 | describe Command do 5 | describe "#require_codeclimate_yml" do 6 | it "exits if the file doesn't exist" do 7 | Dir.chdir(Dir.mktmpdir) do 8 | _, stderr = capture_io do 9 | lambda { Command.new.require_codeclimate_yml }.must_raise SystemExit 10 | end 11 | 12 | stderr.must_match("No '.codeclimate.yml' file found. Run 'codeclimate init' to generate a config file.") 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/cc/analyzer/logging_container_listener.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | class LoggingContainerListener < ContainerListener 4 | def initialize(engine_name, logger) 5 | @engine_name = engine_name 6 | @logger = logger 7 | end 8 | 9 | def started(_data) 10 | logger.info("starting engine #{engine_name}") 11 | end 12 | 13 | def finished(_data) 14 | logger.info("finished engine #{engine_name}") 15 | end 16 | 17 | private 18 | 19 | attr_reader :engine_name, :logger 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM codeclimate/alpine-ruby:b36 2 | 3 | WORKDIR /usr/src/app 4 | COPY Gemfile /usr/src/app/ 5 | COPY Gemfile.lock /usr/src/app/ 6 | COPY VERSION /usr/src/app/ 7 | COPY codeclimate.gemspec /usr/src/app/ 8 | 9 | RUN apk --update add git openssh-client wget build-base && \ 10 | bundle install -j 4 && \ 11 | apk del build-base && rm -fr /usr/share/ri 12 | 13 | RUN wget -O /bin/docker https://get.docker.com/builds/Linux/x86_64/docker-1.6.0 14 | RUN chmod +x /bin/docker 15 | 16 | COPY . /usr/src/app 17 | 18 | ENV FILESYSTEM_DIR /code 19 | 20 | ENTRYPOINT ["/usr/src/app/bin/codeclimate"] 21 | -------------------------------------------------------------------------------- /lib/cc/cli.rb: -------------------------------------------------------------------------------- 1 | require "active_support" 2 | require "active_support/core_ext" 3 | require "cc/analyzer" 4 | require "cc/yaml" 5 | 6 | module CC 7 | module CLI 8 | autoload :Analyze, "cc/cli/analyze" 9 | autoload :Command, "cc/cli/command" 10 | autoload :Console, "cc/cli/console" 11 | autoload :Engines, "cc/cli/engines" 12 | autoload :Help, "cc/cli/help" 13 | autoload :Init, "cc/cli/init" 14 | autoload :Runner, "cc/cli/runner" 15 | autoload :Test, "cc/cli/test" 16 | autoload :ValidateConfig, "cc/cli/validate_config" 17 | autoload :Version, "cc/cli/version" 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /DEVELOPERS.md: -------------------------------------------------------------------------------- 1 | Information for Code Climate CLI developers and contributors. 2 | 3 | ## Testing local changes 4 | 5 | Build a new image using the local sources: 6 | 7 | ```console 8 | make image 9 | ``` 10 | 11 | If you have the CLI installed, the `codeclimate` wrapper will automatically use 12 | this image: 13 | 14 | ```console 15 | codeclimate version 16 | ``` 17 | 18 | Otherwise, invoke the `docker run` command found in the README. 19 | 20 | ## Releasing a new version 21 | 22 | Prep and open a PR bumping the version: 23 | 24 | ```console 25 | bin/prep-release VERSION 26 | ``` 27 | 28 | Once merged, release it: 29 | 30 | ```console 31 | bin/release 32 | ``` 33 | -------------------------------------------------------------------------------- /lib/cc/analyzer/composite_container_listener.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | class CompositeContainerListener < ContainerListener 4 | def initialize(*listeners) 5 | @listeners = listeners 6 | end 7 | 8 | def started(data) 9 | listeners.each { |listener| listener.started(data) } 10 | end 11 | 12 | def timed_out(data) 13 | listeners.each { |listener| listener.timed_out(data) } 14 | end 15 | 16 | def finished(data) 17 | listeners.each { |listener| listener.finished(data) } 18 | end 19 | 20 | private 21 | 22 | attr_reader :listeners 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/cc/analyzer/engine_registry_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::Analyzer 4 | describe EngineRegistry do 5 | describe "#[]" do 6 | it "returns an entry of engines.yml" do 7 | registry = EngineRegistry.new 8 | 9 | registry["madeup"].must_equal nil 10 | registry["rubocop"]["image"].must_equal "codeclimate/codeclimate-rubocop" 11 | end 12 | 13 | it "returns a fake registry entry if in dev mode" do 14 | registry = EngineRegistry.new(true) 15 | 16 | registry["madeup"].must_equal( 17 | "image" => "codeclimate/codeclimate-madeup:latest" 18 | ) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/cc/analyzer/path_entries.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | class PathEntries 4 | def initialize(initial_path) 5 | @initial_path = initial_path.gsub(%r{/$}, "") 6 | end 7 | 8 | def entries 9 | if File.directory?(initial_path) 10 | all_entries.reject do |path| 11 | path.end_with?("/.") || path.start_with?(".git/") 12 | end 13 | else 14 | initial_path 15 | end 16 | end 17 | 18 | private 19 | 20 | attr_reader :initial_path 21 | 22 | def all_entries 23 | Dir.glob("#{initial_path}/**/*", File::FNM_DOTMATCH).push(initial_path) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/cc/analyzer/formatters.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | module Formatters 4 | autoload :Formatter, "cc/analyzer/formatters/formatter" 5 | autoload :JSONFormatter, "cc/analyzer/formatters/json_formatter" 6 | autoload :PlainTextFormatter, "cc/analyzer/formatters/plain_text_formatter" 7 | autoload :Spinner, "cc/analyzer/formatters/spinner" 8 | 9 | FORMATTERS = { 10 | json: JSONFormatter, 11 | text: PlainTextFormatter, 12 | }.freeze 13 | 14 | def self.resolve(name) 15 | FORMATTERS[name.to_sym] or raise InvalidFormatterError, "'#{name}' is not a valid formatter. Valid options are: #{FORMATTERS.keys.join(', ')}" 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /bin/check: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | command -v docker > /dev/null 2>&1 || { 3 | echo "Unable to find \`docker' on your \$PATH, is it installed?" >&2 4 | exit 1 5 | } 6 | 7 | docker version | grep -q "Server version\|Server:" || { 8 | echo "Unable to run \`docker version', the docker daemon may not be running" >&2 9 | 10 | if command -v boot2docker > /dev/null 2>&1; then 11 | echo "Please ensure \`boot2docker up\` succeeds and you've run \`eval \$(boot2docker shellinit)\` in this shell" >&2 12 | else 13 | if [ $UID -eq 0 ]; then 14 | echo "Please ensure \`sudo docker version' succeeds and try again" >&2 15 | else 16 | echo "Please ensure \`docker version' succeeds and try again" >&2 17 | fi 18 | fi 19 | 20 | exit 1 21 | } 22 | -------------------------------------------------------------------------------- /spec/cc/analyzer/path_entries_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::Analyzer 4 | describe PathPatterns do 5 | include FileSystemHelpers 6 | 7 | around do |test| 8 | within_temp_dir { test.call } 9 | end 10 | 11 | describe "#entries" do 12 | it "filters out trailing slashes" do 13 | make_file("lib/foo.rb") 14 | 15 | paths = PathEntries.new("lib/").entries 16 | 17 | paths.sort.must_equal(["lib", "lib/foo.rb"]) 18 | end 19 | 20 | it "filters works without trailing slashes" do 21 | make_file("lib/foo.rb") 22 | 23 | paths = PathEntries.new("lib").entries 24 | 25 | paths.sort.must_equal(["lib", "lib/foo.rb"]) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/cc/analyzer/engine_output.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | class EngineOutput 4 | delegate :blank?, to: :raw_output 5 | delegate :to_json, to: :as_issue 6 | 7 | def initialize(raw_output) 8 | @raw_output = raw_output 9 | end 10 | 11 | def issue? 12 | parsed_output && 13 | parsed_output["type"].present? && 14 | parsed_output["type"].downcase == "issue" 15 | end 16 | 17 | def as_issue 18 | Issue.new(raw_output) 19 | end 20 | 21 | private 22 | 23 | attr_accessor :raw_output 24 | 25 | def parsed_output 26 | JSON.parse(raw_output) 27 | rescue JSON::ParserError 28 | nil 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Release a new version of this repository. 4 | # 5 | # Assumes bin/prep-release was run and the PR merged. 6 | # 7 | # Usage: bin/release 8 | # 9 | ### 10 | set -e 11 | 12 | git checkout master 13 | git pull 14 | 15 | version=$(< VERSION) 16 | 17 | printf "RELEASE %s\n" "$version" 18 | rake release 19 | 20 | docker build --rm -t codeclimate/codeclimate . 21 | docker push codeclimate/codeclimate:latest 22 | docker tag codeclimate/codeclimate "codeclimate/codeclimate:$version" 23 | docker push "codeclimate/codeclimate:$version" 24 | 25 | (cd ../homebrew-formulae/ && bin/release "$version") 26 | 27 | echo "Be sure to update release notes:" 28 | echo "" 29 | echo " https://github.com/codeclimate/codeclimate/releases/new?tag=v$version" 30 | echo "" 31 | -------------------------------------------------------------------------------- /spec/cc/analyzer/logging_container_listener_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::Analyzer 4 | describe LoggingContainerListener do 5 | describe "#started" do 6 | it "logs it" do 7 | logger = stub 8 | listener = LoggingContainerListener.new("foo-engine", logger) 9 | 10 | logger.expects(:info).with { |msg| msg.must_match /foo-engine/ } 11 | 12 | listener.started(stub) 13 | end 14 | end 15 | 16 | describe "#finished" do 17 | it "logs it" do 18 | logger = stub 19 | listener = LoggingContainerListener.new("foo-engine", logger) 20 | 21 | logger.expects(:info).with { |msg| msg.must_match /foo-engine/ } 22 | 23 | listener.finished(stub) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/cc/cli/engines/remove.rb: -------------------------------------------------------------------------------- 1 | require "cc/analyzer" 2 | 3 | module CC 4 | module CLI 5 | module Engines 6 | class Remove < EngineCommand 7 | def run 8 | require_codeclimate_yml 9 | 10 | if !engine_exists? 11 | say "Engine not found. Run 'codeclimate engines:list' for a list of valid engines." 12 | elsif !engine_present_in_yaml? 13 | say "Engine not found in .codeclimate.yml." 14 | else 15 | remove_engine 16 | update_yaml 17 | say "Engine removed from .codeclimate.yml." 18 | end 19 | end 20 | 21 | private 22 | 23 | def remove_engine 24 | parsed_yaml.remove_engine(engine_name) 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/cc/analyzer/formatters/spinner.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | module Formatters 4 | class Spinner 5 | def initialize(text) 6 | @spinner = TTY::Spinner.new(text) 7 | end 8 | 9 | def start 10 | return unless $stdout.tty? 11 | @thread = Thread.new do 12 | loop do 13 | @spinning = true 14 | spinner.spin 15 | sleep 0.075 16 | end 17 | end 18 | end 19 | 20 | def stop(text = "Done!") 21 | if @spinning 22 | spinner.stop(text) 23 | print("\n") 24 | @thread.kill 25 | end 26 | @spinning = false 27 | end 28 | 29 | private 30 | 31 | attr_reader :spinner 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | services: 3 | - docker 4 | environment: 5 | CLOUDSDK_CORE_DISABLE_PROMPTS: 1 6 | PRIVATE_REGISTRY: us.gcr.io/code_climate 7 | 8 | dependencies: 9 | override: 10 | # Used by container_spec 11 | - docker pull alpine 12 | - docker build -t=$PRIVATE_REGISTRY/$CIRCLE_PROJECT_REPONAME:b$CIRCLE_BUILD_NUM . 13 | - bundle install 14 | 15 | test: 16 | override: 17 | - bundle exec rake 18 | 19 | deployment: 20 | registry: 21 | branch: master 22 | commands: 23 | - echo $gcloud_json_key_base64 | sed 's/ //g' | base64 -d > /tmp/gcloud_key.json 24 | - curl https://sdk.cloud.google.com | bash 25 | - gcloud auth activate-service-account --key-file /tmp/gcloud_key.json 26 | - gcloud docker -a 27 | - docker push $PRIVATE_REGISTRY/$CIRCLE_PROJECT_REPONAME:b$CIRCLE_BUILD_NUM 28 | -------------------------------------------------------------------------------- /lib/cc/cli/engines/disable.rb: -------------------------------------------------------------------------------- 1 | require "cc/analyzer" 2 | 3 | module CC 4 | module CLI 5 | module Engines 6 | class Disable < EngineCommand 7 | def run 8 | require_codeclimate_yml 9 | 10 | if !engine_exists? 11 | say "Engine not found. Run 'codeclimate engines:list' for a list of valid engines." 12 | elsif !engine_present_in_yaml? 13 | say "Engine not found in .codeclimate.yml." 14 | elsif !engine_enabled? 15 | say "Engine already disabled." 16 | else 17 | disable_engine 18 | update_yaml 19 | say "Engine disabled." 20 | end 21 | end 22 | 23 | private 24 | 25 | def disable_engine 26 | parsed_yaml.disable_engine(engine_name) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/cc/cli/runner_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::CLI 4 | describe Runner do 5 | describe ".run" do 6 | it "rescues exceptions and prints a friendlier message" do 7 | Explode = Class.new(Command) do 8 | def run 9 | raise StandardError, "boom" 10 | end 11 | end 12 | 13 | _, stderr = capture_io do 14 | Runner.run(["explode"]) 15 | end 16 | 17 | stderr.must_match(/error: \(StandardError\) boom/) 18 | end 19 | end 20 | 21 | describe "#command_name" do 22 | it "parses subclasses" do 23 | Runner.new(["analyze:this"]).command_name.must_equal("Analyze::This") 24 | end 25 | 26 | it "returns class names" do 27 | Runner.new(["analyze"]).command_name.must_equal("Analyze") 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/cc/analyzer/formatters/formatter.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | module Formatters 4 | class Formatter 5 | def initialize(filesystem, output = $stdout) 6 | @filesystem = filesystem 7 | @output = output 8 | end 9 | 10 | def write(_data) 11 | end 12 | 13 | def started 14 | end 15 | 16 | def engine_running(engine) 17 | @current_engine = engine 18 | yield 19 | ensure 20 | @current_engine = nil 21 | end 22 | 23 | def finished 24 | end 25 | 26 | def close 27 | end 28 | 29 | def failed(_output) 30 | end 31 | 32 | InvalidFormatterError = Class.new(StandardError) 33 | 34 | private 35 | 36 | attr_reader :current_engine 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/support/test_container_listener.rb: -------------------------------------------------------------------------------- 1 | class TestContainerListener < CC::Analyzer::ContainerListener 2 | attr_reader \ 3 | :started_image, 4 | :started_name, 5 | :timed_out_image, 6 | :timed_out_name, 7 | :timed_out_seconds, 8 | :finished_image, 9 | :finished_name, 10 | :finished_status, 11 | :finished_stderr 12 | 13 | def started(data) 14 | @started_image = data.image 15 | @started_name = data.name 16 | end 17 | 18 | def timed_out(data) 19 | @timed_out_image = data.image 20 | @timed_out_name = data.name 21 | @timed_out_seconds = data.duration / 1_000 22 | @timed_out = true 23 | end 24 | 25 | def timed_out? 26 | @timed_out 27 | end 28 | 29 | def finished(data) 30 | @finished_image = data.image 31 | @finished_name = data.name 32 | @finished_status = data.status 33 | @finished_stderr = data.stderr 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/cc/analyzer/engine_registry.rb: -------------------------------------------------------------------------------- 1 | require "safe_yaml" 2 | 3 | module CC 4 | module Analyzer 5 | class EngineRegistry 6 | def initialize(dev_mode = false) 7 | @path = File.expand_path("../../../../config/engines.yml", __FILE__) 8 | @config = YAML.safe_load_file(@path) 9 | @dev_mode = dev_mode 10 | end 11 | 12 | def [](engine_name) 13 | if dev_mode? 14 | { "image" => "codeclimate/codeclimate-#{engine_name}:latest" } 15 | else 16 | @config[engine_name] 17 | end 18 | end 19 | 20 | def list 21 | @config 22 | end 23 | 24 | def key?(engine_name) 25 | return true if dev_mode? 26 | list.key?(engine_name) 27 | end 28 | 29 | alias_method :exists?, :key? 30 | 31 | private 32 | 33 | def dev_mode? 34 | @dev_mode 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/cc/analyzer/issue_sorter.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | class IssueSorter 4 | def initialize(issues) 5 | @issues = issues 6 | end 7 | 8 | def by_location 9 | @issues.sort_by { |i| line_or_offset(i) } 10 | end 11 | 12 | private 13 | 14 | def line_or_offset(issue) 15 | location = issue["location"] 16 | 17 | case 18 | when location["lines"] 19 | [location["lines"]["begin"].to_i] 20 | when location["positions"] && location["positions"]["begin"]["line"] 21 | [location["positions"]["begin"]["line"].to_i, location["positions"]["begin"]["column"].to_i] 22 | when location["positions"] && location["positions"]["begin"]["offset"] 23 | [1_000_000_000] # push offsets to end of list 24 | else 25 | [0] # whole-file issues are first 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/cc/cli/engines/enable.rb: -------------------------------------------------------------------------------- 1 | require "cc/analyzer" 2 | 3 | module CC 4 | module CLI 5 | module Engines 6 | class Enable < EngineCommand 7 | def run 8 | require_codeclimate_yml 9 | 10 | if !engine_exists? 11 | say "Engine not found. Run 'codeclimate engines:list' for a list of valid engines." 12 | elsif engine_enabled? 13 | say "Engine already enabled." 14 | pull_docker_images 15 | else 16 | enable_engine 17 | update_yaml 18 | say "Engine added to .codeclimate.yml." 19 | pull_docker_images 20 | end 21 | end 22 | 23 | private 24 | 25 | def pull_docker_images 26 | Engines::Install.new.run 27 | end 28 | 29 | def enable_engine 30 | parsed_yaml.enable_engine(engine_name) 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/cc/analyzer/engine_output_filter.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | class EngineOutputFilter 4 | ISSUE_TYPE = "issue".freeze 5 | 6 | def initialize(config = {}) 7 | @config = config 8 | end 9 | 10 | def filter?(output) 11 | output.blank? || (output.issue? && ignore_issue?(output.as_issue)) 12 | end 13 | 14 | private 15 | 16 | def ignore_issue?(issue) 17 | check_disabled?(issue) || ignore_fingerprint?(issue) 18 | end 19 | 20 | def check_disabled?(issue) 21 | !check_config(issue.check_name).fetch("enabled", true) 22 | end 23 | 24 | def ignore_fingerprint?(issue) 25 | @config.fetch("exclude_fingerprints", []).include?(issue.fingerprint) 26 | end 27 | 28 | def check_config(check_name) 29 | @checks ||= @config.fetch("checks", {}) 30 | @checks.fetch(check_name, {}) 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/cc/analyzer/formatters/json_formatter.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | module Formatters 4 | class JSONFormatter < Formatter 5 | def initialize(filesystem) 6 | @filesystem = filesystem 7 | @has_begun = false 8 | end 9 | 10 | def started 11 | print "[" 12 | end 13 | 14 | def finished 15 | print "]\n" 16 | end 17 | 18 | def write(data) 19 | document = JSON.parse(data) 20 | document["engine_name"] = current_engine.name 21 | 22 | if @has_begun 23 | print ",\n" 24 | end 25 | 26 | print document.to_json 27 | @has_begun = true 28 | end 29 | 30 | def failed(output) 31 | $stderr.puts "\nAnalysis failed with the following output:" 32 | $stderr.puts output 33 | exit 1 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/cc/cli/help.rb: -------------------------------------------------------------------------------- 1 | require "rainbow" 2 | 3 | module CC 4 | module CLI 5 | class Help < Command 6 | def run 7 | say "Usage: codeclimate COMMAND ...\n\nAvailable commands:\n" 8 | commands.each do |command| 9 | say " #{command}" 10 | end 11 | end 12 | 13 | private 14 | 15 | def commands 16 | [ 17 | "analyze [-f format] [-e engine] ", 18 | "console", 19 | "engines:disable #{underline('engine_name')}", 20 | "engines:enable #{underline('engine_name')}", 21 | "engines:install", 22 | "engines:list", 23 | "engines:remove #{underline('engine_name')}", 24 | "help", 25 | "init", 26 | "validate-config", 27 | "version", 28 | ].freeze 29 | end 30 | 31 | def underline(string) 32 | Rainbow.new.wrap(string).underline 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/cc/analyzer/raising_container_listener.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | class RaisingContainerListener < ContainerListener 4 | def initialize(engine_name, failure_ex, timeout_ex) 5 | @engine_name = engine_name 6 | @failure_ex = failure_ex 7 | @timeout_ex = timeout_ex 8 | end 9 | 10 | def timed_out(data) 11 | message = "engine #{engine_name} ran for #{data.duration} seconds" 12 | message << " and was killed" 13 | 14 | raise timeout_ex, message 15 | end 16 | 17 | def finished(data) 18 | unless data.status.success? 19 | message = "engine #{engine_name} failed" 20 | message << " with status #{data.status.exitstatus}" 21 | message << " and stderr \n#{data.stderr}" 22 | 23 | raise failure_ex, message 24 | end 25 | end 26 | 27 | private 28 | 29 | attr_reader :engine_name, :failure_ex, :timeout_ex 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/cc/cli/config.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module CLI 3 | class Config 4 | delegate :to_yaml, to: :config 5 | def initialize 6 | @config = { 7 | "engines" => {}, 8 | "ratings" => { "paths" => [] }, 9 | } 10 | end 11 | 12 | def add_engine(engine_name, engine_config) 13 | config["engines"][engine_name] = { "enabled" => true } 14 | 15 | if engine_config["default_config"].present? 16 | config["engines"][engine_name]["config"] = engine_config["default_config"] 17 | end 18 | 19 | config["ratings"]["paths"] |= engine_config["default_ratings_paths"] 20 | end 21 | 22 | def add_exclude_paths(paths) 23 | config["exclude_paths"] ||= [] 24 | config["exclude_paths"] += paths.map do |path| 25 | if path.ends_with?("/") 26 | "#{path}**/*" 27 | else 28 | path 29 | end 30 | end 31 | end 32 | 33 | private 34 | 35 | attr_reader :config 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/cc/cli/upgrade_config_generator.rb: -------------------------------------------------------------------------------- 1 | require "cc/cli/config_generator" 2 | 3 | module CC 4 | module CLI 5 | class UpgradeConfigGenerator < ConfigGenerator 6 | def can_generate? 7 | errors.blank? 8 | end 9 | 10 | def errors 11 | existing_yaml.errors 12 | end 13 | 14 | def exclude_paths 15 | (existing_yaml.exclude_paths || []).map(&:to_s) 16 | end 17 | 18 | def post_generation_verb 19 | "upgraded" 20 | end 21 | 22 | private 23 | 24 | def engine_eligible?(engine) 25 | base_eligble = super 26 | if engine["upgrade_languages"].present? 27 | base_eligble && (engine["upgrade_languages"] & classic_languages).any? 28 | else 29 | base_eligble 30 | end 31 | end 32 | 33 | def classic_languages 34 | @classic_languages ||= existing_yaml.languages.reject { |_, v| !v }.map(&:first) 35 | end 36 | 37 | def existing_yaml 38 | @existing_yaml ||= CC::Yaml.parse(File.read(CODECLIMATE_YAML)) 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Code Climate, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /lib/cc/cli/engines/engine_command.rb: -------------------------------------------------------------------------------- 1 | require "cc/analyzer" 2 | 3 | module CC 4 | module CLI 5 | module Engines 6 | class EngineCommand < Command 7 | include CC::Analyzer 8 | 9 | private 10 | 11 | def engine_name 12 | @engine_name ||= @args.first 13 | end 14 | 15 | def parsed_yaml 16 | @parsed_yaml ||= CC::Analyzer::Config.new(yaml_content) 17 | end 18 | 19 | def yaml_content 20 | filesystem.read_path(CODECLIMATE_YAML).freeze 21 | end 22 | 23 | def update_yaml 24 | filesystem.write_path(CODECLIMATE_YAML, parsed_yaml.to_yaml) 25 | end 26 | 27 | def engine_present_in_yaml? 28 | parsed_yaml.engine_present?(engine_name) 29 | end 30 | 31 | def engine_enabled? 32 | parsed_yaml.engine_enabled?(engine_name) 33 | end 34 | 35 | def engine_exists? 36 | engine_registry.exists?(engine_name) 37 | end 38 | 39 | def engine_registry_list 40 | engine_registry.list 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /bin/prep-release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Open a PR for releasing a new version of this repository. 4 | # 5 | # Usage: bin/prep-release VERSION 6 | # 7 | ### 8 | set -e 9 | 10 | if [ -z "$1" ]; then 11 | echo "usage: bin/prep-release VERSION" >&2 12 | exit 64 13 | fi 14 | 15 | version=$1 16 | old_version=$(< VERSION) 17 | branch="release-$version" 18 | 19 | if ! bundle exec rake; then 20 | echo "test failure, not releasing" >&2 21 | exit 1 22 | fi 23 | 24 | printf "RELEASE %s => %s\n" "$old_version" "$version" 25 | git checkout master 26 | git pull 27 | 28 | git checkout -b "$branch" 29 | 30 | printf "%s\n" "$version" > VERSION 31 | bundle 32 | git add VERSION Gemfile.lock 33 | git commit -m "Release v$version" 34 | git push origin "$branch" 35 | 36 | branch_head=$(git rev-parse --short $branch) 37 | if command -v hub > /dev/null 2>&1; then 38 | hub pull-request -F - <&2 45 | fi 46 | 47 | echo "After merging the version-bump PR, run bin/release" 48 | -------------------------------------------------------------------------------- /lib/cc/analyzer/source_buffer.rb: -------------------------------------------------------------------------------- 1 | # Adapted from https://github.com/whitequark/parser/blob/master/lib/parser/source/buffer.rb 2 | module CC 3 | module Analyzer 4 | class SourceBuffer 5 | attr_reader :name 6 | attr_reader :source 7 | 8 | def initialize(name, source) 9 | @name = name 10 | @source = source 11 | end 12 | 13 | def decompose_position(position) 14 | line_no, line_begin = line_for(position) 15 | 16 | [1 + line_no, position - line_begin] 17 | end 18 | 19 | def line_count 20 | @source.lines.count 21 | end 22 | 23 | private 24 | 25 | def line_for(position) 26 | line_begins.bsearch do |_, line_begin| 27 | line_begin <= position 28 | end 29 | end 30 | 31 | def line_begins 32 | unless @line_begins 33 | @line_begins = [[0, 0]] 34 | index = 1 35 | 36 | @source.each_char do |char| 37 | @line_begins.unshift [@line_begins.length, index] if char == "\n" 38 | 39 | index += 1 40 | end 41 | end 42 | 43 | @line_begins 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/cc/cli/engines/install.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module CLI 3 | module Engines 4 | class Install < EngineCommand 5 | ImagePullFailure = Class.new(StandardError) 6 | 7 | def run 8 | require_codeclimate_yml 9 | 10 | say "Pulling docker images." 11 | pull_docker_images 12 | end 13 | 14 | private 15 | 16 | def pull_docker_images 17 | engine_names.each do |name| 18 | if engine_registry.exists?(name) 19 | image = engine_image(name) 20 | pull_engine_image(image) 21 | else 22 | warn("unknown engine name: #{name}") 23 | end 24 | end 25 | end 26 | 27 | def engine_names 28 | @engine_names ||= parsed_yaml.engine_names 29 | end 30 | 31 | def engine_image(engine_name) 32 | engine_registry_list[engine_name]["image"] 33 | end 34 | 35 | def pull_engine_image(engine_image) 36 | unless system("docker pull #{engine_image}") 37 | raise ImagePullFailure, "unable to pull image #{engine_image}" 38 | end 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/support/file_system_helpers.rb: -------------------------------------------------------------------------------- 1 | module FileSystemHelpers 2 | def within_temp_dir(&block) 3 | Dir.chdir(Dir.mktmpdir, &block) 4 | end 5 | 6 | def make_filesystem 7 | CC::Analyzer::Filesystem.new(".") 8 | end 9 | 10 | def make_tree(spec) 11 | paths = spec.split(/\s+/).select(&:present?) 12 | paths.each { |path| make_file(path, "") } 13 | end 14 | 15 | def make_file(path, content = "") 16 | directory = File.dirname(path) 17 | 18 | FileUtils.mkdir_p(directory) 19 | File.write(path, content) 20 | end 21 | 22 | def write_fixture_source_files 23 | File.write("cool.rb", "class Cool; end") 24 | FileUtils.mkdir_p("js") 25 | File.write("js/foo.js", "function() {}") 26 | FileUtils.mkdir_p("stylesheets") 27 | File.write("stylesheets/main.css", ".main {}") 28 | FileUtils.mkdir_p("vendor/jquery") 29 | File.write("vendor/foo.css", ".main {}") 30 | File.write("vendor/jquery/jquery.css", ".main {}") 31 | FileUtils.mkdir_p("spec/models") 32 | File.write("spec/spec_helper.rb", ".main {}") 33 | File.write("spec/models/foo.rb", ".main {}") 34 | FileUtils.mkdir_p("config") 35 | File.write("config/foo.rb", ".main {}") 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/cc/analyzer/statsd_container_listener.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | class StatsdContainerListener < ContainerListener 4 | def initialize(engine_name, statsd) 5 | @engine_name = engine_name 6 | @statsd = statsd 7 | end 8 | 9 | def started(_data) 10 | increment("started") 11 | end 12 | 13 | def timed_out(data) 14 | timing("time", data.duration) 15 | increment("result.error") 16 | increment("result.error.timeout") 17 | end 18 | 19 | def finished(data) 20 | timing("time", data.duration) 21 | increment("finished") 22 | 23 | if data.status.success? 24 | increment("result.success") 25 | else 26 | increment("result.error") 27 | end 28 | end 29 | 30 | private 31 | 32 | attr_reader :engine_name, :statsd 33 | 34 | def increment(metric) 35 | statsd.increment("engines.#{metric}") 36 | statsd.increment("engines.names.#{engine_name}.#{metric}") 37 | end 38 | 39 | def timing(metric, ms) 40 | statsd.timing("engines.#{metric}", ms) 41 | statsd.timing("engines.names.#{engine_name}.#{metric}", ms) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /codeclimate.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.join(__FILE__, "../lib")) 2 | VERSION = File.read(File.expand_path("../VERSION", __FILE__)) 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "codeclimate" 6 | s.version = VERSION 7 | s.summary = "Code Climate CLI" 8 | s.license = "MIT" 9 | s.authors = "Code Climate" 10 | s.email = "hello@codeclimate.com" 11 | s.homepage = "https://codeclimate.com" 12 | s.description = "Code Climate command line tool" 13 | 14 | s.files = Dir["lib/**/*.rb"] + Dir["bin/*"] + Dir.glob("config/**/*", File::FNM_DOTMATCH) 15 | s.require_paths = ["lib"] 16 | s.bindir = "bin" 17 | s.executables = %w(check codeclimate-init) 18 | 19 | s.add_dependency "activesupport", "~> 4.2", ">= 4.2.1" 20 | s.add_dependency "tty-spinner", "~> 0.1.0" 21 | s.add_dependency "codeclimate-yaml", "~> 0.6.1" 22 | s.add_dependency "faraday", "~> 0.9.1" 23 | s.add_dependency "faraday_middleware", "~> 0.9.1" 24 | s.add_dependency "highline", "~> 1.7", ">= 1.7.2" 25 | s.add_dependency "posix-spawn", "~> 0.3", ">= 0.3.11" 26 | s.add_dependency "pry", "~> 0.10.1" 27 | s.add_dependency "rainbow", "~> 2.0", ">= 2.0.0" 28 | s.add_dependency "safe_yaml", "~> 1.0", ">= 1.0.4" 29 | end 30 | -------------------------------------------------------------------------------- /lib/cc/analyzer/location_description.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | class LocationDescription 4 | def initialize(source_buffer, location, suffix = "") 5 | @source_buffer = source_buffer 6 | @location = location 7 | @suffix = suffix 8 | end 9 | 10 | def to_s 11 | if location["lines"] 12 | begin_line = location["lines"]["begin"] 13 | end_line = location["lines"]["end"] 14 | elsif location["positions"] 15 | begin_line = position_to_line(location["positions"]["begin"]) 16 | end_line = position_to_line(location["positions"]["end"]) 17 | end 18 | 19 | str = render_lines(begin_line, end_line) 20 | str << suffix unless str.blank? 21 | str 22 | end 23 | 24 | private 25 | 26 | attr_reader :location, :suffix 27 | 28 | def render_lines(begin_line, end_line) 29 | if end_line == begin_line 30 | begin_line.to_s 31 | else 32 | "#{begin_line}-#{end_line}" 33 | end 34 | end 35 | 36 | def position_to_line(position) 37 | if position["line"] 38 | position["line"] 39 | else 40 | @source_buffer.decompose_position(position["offset"]).first 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/cc/analyzer/path_filter.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | class PathFilter 4 | attr_reader :paths 5 | 6 | def initialize(paths) 7 | @paths = paths 8 | end 9 | 10 | def reject_unreadable_paths 11 | @paths = paths - unreadable_path_entries 12 | self 13 | end 14 | 15 | def reject_paths(ignore_paths) 16 | @paths = paths - ignore_paths 17 | self 18 | end 19 | 20 | def select_readable_files 21 | @paths = paths.select { |path| File.exist?(path) && FileUtils.readable_by_all?(path) } 22 | self 23 | end 24 | 25 | def reject_symlinks 26 | @paths = paths.reject { |path| File.symlink?(path) } 27 | self 28 | end 29 | 30 | def reject_globs(globs) 31 | patterns = PathPatterns.new(globs) 32 | @paths = paths.reject { |path| patterns.match?(pathpatterns.match?(path)) } 33 | self 34 | end 35 | 36 | private 37 | 38 | def unreadable_path_entries 39 | @_unreadable_path_entries ||= 40 | unreadable_paths.flat_map { |path| PathEntries.new(path).entries } 41 | end 42 | 43 | def unreadable_paths 44 | paths.select do |path| 45 | File.directory?(path) && !FileUtils.readable_by_all?(path) 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/cc/analyzer/raising_container_listener_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::Analyzer 4 | describe RaisingContainerListener do 5 | describe "#timed_out" do 6 | it "raises the given timeout exception" do 7 | timeout_ex = Class.new(StandardError) 8 | listener = RaisingContainerListener.new("engine", nil, timeout_ex) 9 | 10 | ex = ->() { listener.timed_out(stub(duration: 10)) }.must_raise(timeout_ex) 11 | ex.message.must_match /engine ran for 10 seconds/ 12 | end 13 | end 14 | 15 | describe "#failure" do 16 | it "does nothing on success" do 17 | listener = RaisingContainerListener.new("engine", nil, nil) 18 | listener.finished(stub(status: stub(success?: true), stderr: "")) 19 | end 20 | 21 | it "raises the given failure exception on error" do 22 | failure_ex = Class.new(StandardError) 23 | listener = RaisingContainerListener.new("engine", failure_ex, nil) 24 | data = stub( 25 | status: stub(success?: false, exitstatus: 1), 26 | stderr: "some error", 27 | ) 28 | 29 | ex = ->() { listener.finished(data) }.must_raise(failure_ex) 30 | ex.message.must_match /engine failed/ 31 | ex.message.must_match /status 1/ 32 | ex.message.must_match /some error/ 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/cc/analyzer/issue.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | class Issue 4 | SPEC_ISSUE_ATTRIBUTES = %w[ 5 | categories 6 | check_name 7 | content 8 | description 9 | location 10 | other_locations 11 | remediation_points 12 | severity 13 | type 14 | ] 15 | 16 | def initialize(output) 17 | @output = output 18 | end 19 | 20 | def as_json(*) 21 | parsed_output.reverse_merge!( 22 | "fingerprint" => fingerprint, 23 | ) 24 | end 25 | 26 | def fingerprint 27 | parsed_output.fetch("fingerprint", default_fingerprint) 28 | end 29 | 30 | # Allow access to hash keys as methods 31 | SPEC_ISSUE_ATTRIBUTES.each do |key| 32 | define_method(key) do 33 | parsed_output[key] 34 | end 35 | end 36 | 37 | private 38 | 39 | attr_reader :output 40 | 41 | def default_fingerprint 42 | digest = Digest::MD5.new 43 | digest << path 44 | digest << "|" 45 | digest << check_name.to_s 46 | digest.to_s 47 | end 48 | 49 | def parsed_output 50 | @parsed_output ||= JSON.parse(output) 51 | end 52 | 53 | def path 54 | parsed_output.fetch("location", {}).fetch("path", "") 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/cc/analyzer/path_patterns.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | class PathPatterns 4 | def initialize(patterns, root = Dir.pwd) 5 | @patterns = patterns 6 | @root = root 7 | end 8 | 9 | def match?(path) 10 | expanded.include?(path) 11 | end 12 | 13 | def expanded 14 | @expanded ||= expand 15 | end 16 | 17 | private 18 | 19 | def expand 20 | results = Dir.chdir(@root) do 21 | @patterns.flat_map do |pattern| 22 | value = glob_value(pattern) 23 | Dir.glob(value) 24 | end 25 | end 26 | 27 | results.sort.uniq 28 | end 29 | 30 | def glob_value(pattern) 31 | # FIXME: there exists a temporary workaround whereby **-style globs 32 | # are translated to **/*-style globs within cc-yaml's custom 33 | # Glob#value method. It was thought that that would work correctly 34 | # with Dir.glob but it turns out we have to actually invoke #value 35 | # directrly for this to work. We need to guard this on class (not 36 | # respond_to?) because our mocking framework adds a #value method to 37 | # all objects, apparently. 38 | if pattern.is_a?(CC::Yaml::Nodes::Glob) 39 | pattern.value 40 | else 41 | pattern 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/cc/analyzer/issue_sorter_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::Analyzer 4 | describe IssueSorter do 5 | describe "#by_location" do 6 | it "orders items correctly" do 7 | issues = [ 8 | whole_file = { "location" => { } }, 9 | offset_600 = { "location" => { "positions" => { "begin" => { "offset" => 600 } } } }, 10 | line_3 = { "location" => { "lines" => { "begin" => 3 } } }, 11 | line_4 = { "location" => { "lines" => { "begin" => 4 } } }, 12 | line_15 = { "location" => { "lines" => { "begin" => 15 } } }, 13 | line_4_col_1 = { "location" => { "positions" => { "begin" => { "line" => 4, "column" => 1} } } }, 14 | line_4_col_2 = { "location" => { "positions" => { "begin" => { "line" => 4, "column" => 2} } } }, 15 | line_4_col_83 = { "location" => { "positions" => { "begin" => { "line" => 4, "column" => 83} } } }, 16 | line_1_col_50 = { "location" => { "positions" => { "begin" => { "line" => 1, "column" => 50} } } }, 17 | ].shuffle 18 | 19 | IssueSorter.new(issues).by_location.must_equal([ 20 | whole_file, 21 | line_1_col_50, 22 | line_3, 23 | line_4, 24 | line_4_col_1, 25 | line_4_col_2, 26 | line_4_col_83, 27 | line_15, 28 | offset_600, 29 | ]) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/cc/analyzer/formatters/plain_text_formatter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module CC::Analyzer::Formatters 4 | describe PlainTextFormatter do 5 | include Factory 6 | 7 | let(:formatter) do 8 | filesystem ||= CC::Analyzer::Filesystem.new(ENV['FILESYSTEM_DIR']) 9 | PlainTextFormatter.new(filesystem) 10 | end 11 | 12 | describe "#write" do 13 | it "raises an error" do 14 | engine = stub(name: "engine") 15 | 16 | runner = lambda do 17 | capture_io do 18 | write_from_engine(formatter, engine, "type" => "thing") 19 | end 20 | end 21 | 22 | runner.must_raise(RuntimeError, "Invalid type found: thing") 23 | end 24 | end 25 | 26 | describe "#finished" do 27 | it "outputs a breakdown" do 28 | engine = stub(name: "cool_engine") 29 | 30 | stdout, _ = capture_io do 31 | write_from_engine(formatter, engine, sample_issue) 32 | formatter.finished 33 | end 34 | 35 | stdout.must_match("config.rb (1 issue)") 36 | stdout.must_match("Missing top-level class documentation comment") 37 | stdout.must_match("[cool_engine]") 38 | end 39 | end 40 | 41 | def write_from_engine(formatter, engine, issue) 42 | formatter.engine_running(engine) do 43 | formatter.write(issue.to_json) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/cc/analyzer/composite_container_listener_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::Analyzer 4 | describe CompositeContainerListener do 5 | describe "#started" do 6 | it "delegates to the listeners given" do 7 | listener_a = stub 8 | listener_b = stub 9 | 10 | data = stub 11 | listener_a.expects(:started).with(data) 12 | listener_b.expects(:started).with(data) 13 | 14 | listener = CompositeContainerListener.new(listener_a, listener_b) 15 | listener.started(data) 16 | end 17 | end 18 | 19 | describe "#timed_out" do 20 | it "delegates to the listeners given" do 21 | listener_a = stub 22 | listener_b = stub 23 | 24 | data = stub 25 | listener_a.expects(:timed_out).with(data) 26 | listener_b.expects(:timed_out).with(data) 27 | 28 | listener = CompositeContainerListener.new(listener_a, listener_b) 29 | listener.timed_out(data) 30 | end 31 | end 32 | 33 | describe "#finished" do 34 | it "delegates to the listeners given" do 35 | listener_a = stub 36 | listener_b = stub 37 | 38 | data = stub 39 | listener_a.expects(:finished).with(data) 40 | listener_b.expects(:finished).with(data) 41 | 42 | listener = CompositeContainerListener.new(listener_a, listener_b) 43 | listener.finished(data) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/cc/analyzer/location_description_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::Analyzer 4 | describe LocationDescription do 5 | describe "#to_s" do 6 | it "adds the suffix" do 7 | location = { "lines" => { "begin" => 1, "end" => 3 } } 8 | 9 | LocationDescription.new(Object.new, location, "!").to_s.must_equal("1-3!") 10 | end 11 | 12 | it "with lines" do 13 | location = {"lines" => {"begin" => 1, "end" => 3}} 14 | 15 | LocationDescription.new(Object.new, location).to_s.must_equal("1-3") 16 | end 17 | 18 | it "with linecols" do 19 | location = { 20 | "positions" => { 21 | "begin" => { 22 | "line" => 1, 23 | "column" => 2 24 | }, 25 | "end" => { 26 | "line" => 3, 27 | "column" => 4 28 | } 29 | } 30 | } 31 | 32 | LocationDescription.new(Object.new, location).to_s.must_equal("1-3") 33 | end 34 | 35 | it "with offsets" do 36 | location = { 37 | "positions" => { 38 | "begin" => { 39 | "offset" => 1 40 | }, 41 | "end" => { 42 | "offset" => 5 43 | } 44 | } 45 | } 46 | 47 | source_buffer = SourceBuffer.new("foo.rb", "foo\nbar") 48 | LocationDescription.new(source_buffer, location).to_s.must_equal("1-2") 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/cc/analyzer/filesystem.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | class Filesystem 4 | def initialize(root) 5 | @root = root 6 | end 7 | 8 | def exist?(path) 9 | File.exist?(path_for(path)) 10 | end 11 | 12 | def source_buffer_for(path) 13 | SourceBuffer.new(path, read_path(path)) 14 | end 15 | 16 | def read_path(path) 17 | File.read(path_for(path)) 18 | end 19 | 20 | def write_path(path, content) 21 | File.write(path_for(path), content) 22 | File.chown(root_uid, root_gid, path_for(path)) 23 | end 24 | 25 | def any?(&block) 26 | file_paths.any?(&block) 27 | end 28 | 29 | def files_matching(globs) 30 | Dir.chdir(@root) do 31 | globs.map do |glob| 32 | Dir.glob(glob) 33 | end.flatten.sort.uniq 34 | end 35 | end 36 | 37 | private 38 | 39 | def file_paths 40 | @file_paths ||= Dir.chdir(@root) do 41 | `find . -type f -print0`.strip.split("\0").map do |path| 42 | path.sub(%r{^\.\/}, "") 43 | end 44 | end 45 | end 46 | 47 | def path_for(path) 48 | File.join(@root, path) 49 | end 50 | 51 | def root_uid 52 | root_stat.uid 53 | end 54 | 55 | def root_gid 56 | root_stat.gid 57 | end 58 | 59 | def root_stat 60 | @root_stat ||= File.stat(@root) 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/cc/cli/runner.rb: -------------------------------------------------------------------------------- 1 | require "active_support" 2 | require "active_support/core_ext" 3 | require "safe_yaml" 4 | 5 | module CC 6 | module CLI 7 | class Runner 8 | def self.run(argv) 9 | new(argv).run 10 | rescue => ex 11 | $stderr.puts("error: (#{ex.class}) #{ex.message}") 12 | 13 | if ENV["CODECLIMATE_DEBUG"] 14 | $stderr.puts("backtrace: #{ex.backtrace.join("\n\t")}") 15 | end 16 | end 17 | 18 | def initialize(args) 19 | SafeYAML::OPTIONS[:default_mode] = :safe 20 | 21 | @args = args 22 | end 23 | 24 | def run 25 | if command_class 26 | command = command_class.new(command_arguments) 27 | command.execute 28 | else 29 | command_not_found 30 | end 31 | end 32 | 33 | def command_not_found 34 | $stderr.puts "unknown command #{command}" 35 | exit 1 36 | end 37 | 38 | def command_class 39 | CLI.const_get(command_name) 40 | rescue NameError 41 | nil 42 | end 43 | 44 | def command_name 45 | case command 46 | when nil, "-h", "-?", "--help" then "Help" 47 | when "-v", "--version" then "Version" 48 | else command.sub(":", "::").underscore.camelize 49 | end 50 | end 51 | 52 | def command_arguments 53 | @args[1..-1] 54 | end 55 | 56 | def command 57 | @args.first 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/cc/analyzer/path_minimizer_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::Analyzer 4 | describe PathMinimizer do 5 | include FileSystemHelpers 6 | 7 | around do |test| 8 | within_temp_dir { test.call } 9 | end 10 | 11 | describe "#minimize" do 12 | describe "when all files match" do 13 | it "returns ./" do 14 | make_file("lib/tasks/foo.rb") 15 | make_file("foo.rb") 16 | 17 | minimizer = PathMinimizer.new(["lib", "lib/tasks", "lib/tasks/foo.rb", "foo.rb"]) 18 | minimizer.minimize.must_equal(["./"]) 19 | end 20 | end 21 | 22 | it "breaks down lists of files into paths" do 23 | make_file("lib/tasks/foo.rb") 24 | make_file("foo.rb") 25 | 26 | minimizer = PathMinimizer.new(["lib", "lib/tasks", "lib/tasks/foo.rb"]) 27 | minimizer.minimize.must_equal(["lib/"]) 28 | end 29 | 30 | it "breaks down abitrarily nested lists of files into paths" do 31 | make_file("lib/tasks/foo.rb") 32 | make_file("lib/tasks/bar/foo.rb") 33 | make_file("lib/tasks/bar/baz/foo.rb") 34 | make_file("lib/tasks/foo.js") 35 | make_file("foo.rb") 36 | 37 | minimizer = PathMinimizer.new([ 38 | "lib", 39 | "lib/tasks", 40 | "lib/tasks/foo.rb", 41 | "lib/tasks/bar", 42 | "lib/tasks/bar/foo.rb", 43 | "lib/tasks/bar/baz", 44 | "lib/tasks/bar/baz/foo.rb" 45 | ]) 46 | 47 | minimizer.minimize.must_equal(["lib/tasks/foo.rb", "lib/tasks/bar/"]) 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/cc/cli/engines/install_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::CLI::Engines 4 | describe Install do 5 | describe "#run" do 6 | it "pulls uninstalled images using docker" do 7 | stub_config(engine_names: ["madeup"]) 8 | stub_engine_exists("madeup") 9 | stub_engine_image("madeup") 10 | 11 | expect_system("docker pull madeup_img") 12 | 13 | capture_io { Install.new.run } 14 | end 15 | 16 | it "warns for invalid engine names" do 17 | stub_config(engine_names: ["madeup"]) 18 | 19 | stdout, _ = capture_io do 20 | Install.new.run 21 | end 22 | 23 | stdout.must_match(/unknown engine name: madeup/) 24 | end 25 | 26 | it "errors if an image is unable to be pulled" do 27 | stub_config(engine_names: ["madeup"]) 28 | stub_engine_exists("madeup") 29 | stub_engine_image("madeup") 30 | 31 | expect_system("docker pull madeup_img", false) 32 | 33 | capture_io do 34 | lambda { Install.new.run }.must_raise(Install::ImagePullFailure) 35 | end 36 | end 37 | end 38 | 39 | def expect_system(cmd, result = true) 40 | Install.any_instance.expects(:system).with(cmd).returns(result) 41 | end 42 | 43 | def stub_config(stubs) 44 | config = stub(stubs) 45 | CC::Analyzer::Config.stubs(:new).returns(config) 46 | end 47 | 48 | def stub_engine_exists(engine) 49 | CC::Analyzer::EngineRegistry.any_instance.stubs(:exists?).with(engine).returns(true) 50 | end 51 | 52 | def stub_engine_image(engine) 53 | EngineCommand.any_instance.stubs(:engine_registry_list).returns("#{engine}" => { "image" => "#{engine}_img" }) 54 | end 55 | end 56 | end 57 | 58 | -------------------------------------------------------------------------------- /spec/cc/cli/config_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "cc/cli/config" 3 | 4 | module CC::CLI 5 | describe CC::CLI::Config do 6 | describe "#add_engine" do 7 | it "enables the passed in engine" do 8 | config = CC::CLI::Config.new() 9 | engine_config = { 10 | "default_ratings_paths" => ["foo"] 11 | } 12 | 13 | config.add_engine("foo", engine_config) 14 | 15 | engine = YAML.load(config.to_yaml)["engines"]["foo"] 16 | engine.must_equal({ "enabled" => true }) 17 | end 18 | 19 | it "copies over default configuration" do 20 | config = CC::CLI::Config.new() 21 | engine_config = { 22 | "default_config" => { "awesome" => true }, 23 | "default_ratings_paths" => ["foo"] 24 | } 25 | 26 | config.add_engine("foo", engine_config) 27 | 28 | engine = YAML.load(config.to_yaml)["engines"]["foo"] 29 | engine.must_equal({ 30 | "enabled" => true, 31 | "config" => { 32 | "awesome" => true 33 | } 34 | }) 35 | end 36 | end 37 | 38 | describe "#add_exclude_paths" do 39 | it "adds exclude paths to config with glob" do 40 | config = CC::CLI::Config.new() 41 | config.add_exclude_paths(["foo/"]) 42 | 43 | exclude_paths = YAML.load(config.to_yaml)["exclude_paths"] 44 | exclude_paths.must_equal(["foo/**/*"]) 45 | end 46 | 47 | it "does not glob paths that aren't directories" do 48 | config = CC::CLI::Config.new() 49 | config.add_exclude_paths(["foo.rb"]) 50 | 51 | exclude_paths = YAML.load(config.to_yaml)["exclude_paths"] 52 | exclude_paths.must_equal(["foo.rb"]) 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/cc/cli/command.rb: -------------------------------------------------------------------------------- 1 | require "highline" 2 | require "active_support" 3 | require "active_support/core_ext" 4 | require "rainbow" 5 | 6 | module CC 7 | module CLI 8 | class Command 9 | CODECLIMATE_YAML = ".codeclimate.yml".freeze 10 | 11 | def initialize(args = []) 12 | @args = args 13 | end 14 | 15 | def run 16 | $stderr.puts "unknown command #{self.class.name.split('::').last.underscore}" 17 | end 18 | 19 | def self.command_name 20 | name[/[^:]*$/].split(/(?=[A-Z])/).map(&:downcase).join("-") 21 | end 22 | 23 | def execute 24 | run 25 | end 26 | 27 | def success(message) 28 | terminal.say colorize(message, :green) 29 | end 30 | 31 | def say(message) 32 | terminal.say message 33 | end 34 | 35 | def warn(message) 36 | terminal.say(colorize("WARNING: #{message}", :yellow)) 37 | end 38 | 39 | def fatal(message) 40 | $stderr.puts colorize(message, :red) 41 | exit 1 42 | end 43 | 44 | def require_codeclimate_yml 45 | unless filesystem.exist?(CODECLIMATE_YAML) 46 | fatal("No '.codeclimate.yml' file found. Run 'codeclimate init' to generate a config file.") 47 | end 48 | end 49 | 50 | private 51 | 52 | def colorize(string, *args) 53 | rainbow.wrap(string).color(*args) 54 | end 55 | 56 | def rainbow 57 | @rainbow ||= Rainbow.new 58 | end 59 | 60 | def filesystem 61 | @filesystem ||= CC::Analyzer::Filesystem.new(ENV["FILESYSTEM_DIR"]) 62 | end 63 | 64 | def terminal 65 | @terminal ||= HighLine.new($stdin, $stdout) 66 | end 67 | 68 | def engine_registry 69 | @engine_registry ||= CC::Analyzer::EngineRegistry.new 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/cc/analyzer.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module Analyzer 3 | autoload :CompositeContainerListener, "cc/analyzer/composite_container_listener" 4 | autoload :Config, "cc/analyzer/config" 5 | autoload :Container, "cc/analyzer/container" 6 | autoload :ContainerListener, "cc/analyzer/container_listener" 7 | autoload :Engine, "cc/analyzer/engine" 8 | autoload :EngineOutput, "cc/analyzer/engine_output" 9 | autoload :EngineOutputFilter, "cc/analyzer/engine_output_filter" 10 | autoload :EngineRegistry, "cc/analyzer/engine_registry" 11 | autoload :EnginesConfigBuilder, "cc/analyzer/engines_config_builder" 12 | autoload :EnginesRunner, "cc/analyzer/engines_runner" 13 | autoload :Filesystem, "cc/analyzer/filesystem" 14 | autoload :Formatters, "cc/analyzer/formatters" 15 | autoload :IncludePathsBuilder, "cc/analyzer/include_paths_builder" 16 | autoload :Issue, "cc/analyzer/issue" 17 | autoload :IssueSorter, "cc/analyzer/issue_sorter" 18 | autoload :LocationDescription, "cc/analyzer/location_description" 19 | autoload :LoggingContainerListener, "cc/analyzer/logging_container_listener" 20 | autoload :PathPatterns, "cc/analyzer/path_patterns" 21 | autoload :PathMinimizer, "cc/analyzer/path_minimizer" 22 | autoload :RaisingContainerListener, "cc/analyzer/raising_container_listener" 23 | autoload :SourceBuffer, "cc/analyzer/source_buffer" 24 | autoload :StatsdContainerListener, "cc/analyzer/statsd_container_listener" 25 | 26 | class DummyStatsd 27 | def method_missing(*) 28 | yield if block_given? 29 | end 30 | end 31 | 32 | class DummyLogger 33 | def method_missing(*) 34 | yield if block_given? 35 | end 36 | end 37 | 38 | cattr_accessor :statsd, :logger 39 | self.statsd = DummyStatsd.new 40 | self.logger = DummyLogger.new 41 | 42 | UnreadableFileError = Class.new(StandardError) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/cc/analyzer/path_patterns_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::Analyzer 4 | describe PathPatterns do 5 | include FileSystemHelpers 6 | 7 | around do |test| 8 | within_temp_dir { test.call } 9 | end 10 | 11 | describe "expanded" do 12 | it "matches files for all patterns at any level" do 13 | make_tree(<<-EOM) 14 | foo.rb 15 | foo.php 16 | foo.js 17 | foo/bar.rb 18 | foo/bar.php 19 | foo/bar.js 20 | foo/bar/baz.rb 21 | foo/bar/baz.php 22 | foo/bar/baz.js 23 | EOM 24 | patterns = PathPatterns.new(%w[ **/*.rb **/*.js ]) 25 | expected = %w[ 26 | foo.rb 27 | foo.js 28 | foo/bar.rb 29 | foo/bar.js 30 | foo/bar/baz.rb 31 | foo/bar/baz.js 32 | ] 33 | 34 | patterns.expanded.sort.must_equal(expected.sort) 35 | end 36 | 37 | it "works with patterns returned by cc-yaml" do 38 | make_tree("foo.rb foo.js foo.php") 39 | config = CC::Yaml.parse(<<-EOYAML) 40 | engines: 41 | rubocop: 42 | enabled: true 43 | exclude_paths: 44 | - "*.rb" 45 | - "*.js" 46 | EOYAML 47 | 48 | patterns = PathPatterns.new(config.exclude_paths) 49 | 50 | patterns.expanded.sort.must_equal(%w[ foo.js foo.rb ]) 51 | end 52 | 53 | it "works with cc-yaml normalized paths and Dir.glob" do 54 | make_tree("foo/bar.rb") 55 | config = CC::Yaml.parse(<<-EOYAML) 56 | engines: 57 | rubocop: 58 | enabled: true 59 | ratings: 60 | paths: 61 | - "**.rb" 62 | EOYAML 63 | 64 | patterns = PathPatterns.new(config.ratings.paths) 65 | 66 | patterns.expanded.sort.must_equal(%w[ foo/bar.rb ]) 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/cc/analyzer/engines_runner.rb: -------------------------------------------------------------------------------- 1 | require "securerandom" 2 | 3 | module CC 4 | module Analyzer 5 | class EnginesRunner 6 | InvalidEngineName = Class.new(StandardError) 7 | NoEnabledEngines = Class.new(StandardError) 8 | 9 | def initialize(registry, formatter, source_dir, config, requested_paths = [], container_label = nil) 10 | @registry = registry 11 | @formatter = formatter 12 | @source_dir = source_dir 13 | @config = config 14 | @requested_paths = requested_paths 15 | @container_label = container_label 16 | end 17 | 18 | def run(container_listener = ContainerListener.new) 19 | raise NoEnabledEngines if engines.empty? 20 | 21 | @formatter.started 22 | 23 | engines.each { |engine| run_engine(engine, container_listener) } 24 | 25 | @formatter.finished 26 | ensure 27 | @formatter.close if @formatter.respond_to?(:close) 28 | end 29 | 30 | private 31 | 32 | attr_reader :requested_paths 33 | 34 | def build_engine(built_config) 35 | Engine.new( 36 | built_config.name, 37 | built_config.registry_entry, 38 | built_config.code_path, 39 | built_config.config, 40 | built_config.container_label, 41 | ) 42 | end 43 | 44 | def configs 45 | EnginesConfigBuilder.new( 46 | registry: @registry, 47 | config: @config, 48 | container_label: @container_label, 49 | source_dir: @source_dir, 50 | requested_paths: requested_paths, 51 | ).run 52 | end 53 | 54 | def engines 55 | @engines ||= configs.map { |result| build_engine(result) } 56 | end 57 | 58 | def run_engine(engine, container_listener) 59 | @formatter.engine_running(engine) do 60 | engine.run(@formatter, container_listener) 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/cc/analyzer/statsd_container_listener_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::Analyzer 4 | describe StatsdContainerListener do 5 | describe "#started" do 6 | it "increments a metric in statsd" do 7 | statsd = stub(increment: nil) 8 | statsd.expects(:increment).with("engines.started") 9 | 10 | listener = StatsdContainerListener.new("engine", statsd) 11 | listener.started(nil) 12 | end 13 | end 14 | 15 | describe "#timed_out" do 16 | it "increments a metric in statsd" do 17 | statsd = stub(timing: nil, increment: nil) 18 | statsd.expects(:timing).with("engines.time", 10) 19 | statsd.expects(:increment).with("engines.result.error") 20 | statsd.expects(:increment).with("engines.result.error.timeout") 21 | 22 | listener = StatsdContainerListener.new("engine", statsd) 23 | listener.timed_out(stub(duration: 10)) 24 | end 25 | 26 | end 27 | 28 | describe "#finished" do 29 | it "increments a metric for success" do 30 | statsd = stub(timing: nil, increment: nil) 31 | statsd.expects(:timing).with("engines.time", 10) 32 | statsd.expects(:increment).with("engines.finished") 33 | statsd.expects(:increment).with("engines.result.success") 34 | 35 | listener = StatsdContainerListener.new("engine", statsd) 36 | listener.finished(stub(duration: 10, status: stub(success?: true))) 37 | end 38 | 39 | it "increments a metric for failure" do 40 | statsd = stub(timing: nil, increment: nil) 41 | statsd.expects(:timing).with("engines.time", 10) 42 | statsd.expects(:increment).with("engines.finished") 43 | statsd.expects(:increment).with("engines.result.error") 44 | 45 | listener = StatsdContainerListener.new("engine", statsd) 46 | listener.finished(stub(duration: 10, status: stub(success?: false))) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/cc/analyzer/include_paths_builder.rb: -------------------------------------------------------------------------------- 1 | require "file_utils_ext" 2 | require "cc/analyzer/path_minimizer" 3 | require "cc/analyzer/path_filter" 4 | 5 | module CC 6 | module Analyzer 7 | class IncludePathsBuilder 8 | IGNORE_PATHS = [".", "..", ".git"].freeze 9 | 10 | attr_reader :cc_include_paths 11 | 12 | def initialize(cc_exclude_paths, cc_include_paths = []) 13 | @cc_exclude_paths = cc_exclude_paths 14 | @cc_include_paths = cc_include_paths 15 | end 16 | 17 | def build 18 | PathMinimizer.new(paths_filter.paths).minimize.uniq 19 | end 20 | 21 | private 22 | 23 | def paths_filter 24 | @_paths = 25 | PathFilter.new(include_paths). 26 | reject_paths(ignored_files). 27 | reject_unreadable_paths. 28 | select_readable_files. 29 | reject_symlinks 30 | end 31 | 32 | def include_paths 33 | if @cc_include_paths.empty? 34 | all_paths 35 | else 36 | @cc_include_paths.flat_map do |path| 37 | PathEntries.new(path).entries 38 | end 39 | end 40 | end 41 | 42 | def all_paths 43 | Dir.glob("*", File::FNM_DOTMATCH). 44 | reject { |path| IncludePathsBuilder::IGNORE_PATHS.include?(path) }. 45 | flat_map { |path| PathEntries.new(path).entries } 46 | end 47 | 48 | def ignored_files 49 | return @_ignored_files if @_ignored_files 50 | 51 | Tempfile.open(".cc_gitignore") do |tmp| 52 | tmp.write(File.read(".gitignore")) if File.file?(".gitignore") 53 | tmp << @cc_exclude_paths.join("\n") 54 | tmp.close 55 | tracked_and_ignored = `git ls-files -zi -X #{tmp.path} 2>/dev/null`.split("\0") 56 | untracked_and_ignored = `git ls-files -zio -X #{tmp.path} 2>/dev/null`.split("\0") 57 | @_ignored_files = tracked_and_ignored + untracked_and_ignored 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/cc/cli/config_generator.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module CLI 3 | class ConfigGenerator 4 | CODECLIMATE_YAML = Command::CODECLIMATE_YAML 5 | AUTO_EXCLUDE_PATHS = %w(config/ db/ dist/ features/ node_modules/ script/ spec/ test/ tests/ vendor/).freeze 6 | 7 | def self.for(filesystem, engine_registry, upgrade_requested) 8 | if upgrade_requested && upgrade_needed?(filesystem) 9 | UpgradeConfigGenerator.new(filesystem, engine_registry) 10 | else 11 | ConfigGenerator.new(filesystem, engine_registry) 12 | end 13 | end 14 | 15 | def initialize(filesystem, engine_registry) 16 | @filesystem = filesystem 17 | @engine_registry = engine_registry 18 | end 19 | 20 | def can_generate? 21 | true 22 | end 23 | 24 | def eligible_engines 25 | return @eligible_engines if @eligible_engines 26 | 27 | engines = engine_registry.list 28 | @eligible_engines = engines.each_with_object({}) do |(name, config), result| 29 | if engine_eligible?(config) 30 | result[name] = config 31 | end 32 | end 33 | end 34 | 35 | def errors 36 | [] 37 | end 38 | 39 | def exclude_paths 40 | AUTO_EXCLUDE_PATHS.select { |path| filesystem.exist?(path) } 41 | end 42 | 43 | def post_generation_verb 44 | "generated" 45 | end 46 | 47 | private 48 | 49 | attr_reader :engine_registry, :filesystem 50 | 51 | def self.upgrade_needed?(filesystem) 52 | if filesystem.exist?(CODECLIMATE_YAML) 53 | YAML.safe_load(File.read(CODECLIMATE_YAML))["languages"].present? 54 | end 55 | end 56 | 57 | def engine_eligible?(engine) 58 | !engine["community"] && engine["enable_regexps"].present? && files_exist?(engine) 59 | end 60 | 61 | def files_exist?(engine) 62 | filesystem.any? do |path| 63 | engine["enable_regexps"].any? { |re| Regexp.new(re).match(path) } 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/support/factory.rb: -------------------------------------------------------------------------------- 1 | module Factory 2 | extend self 3 | 4 | def yaml_with_rubocop_enabled 5 | %{ 6 | engines: 7 | rubocop: 8 | enabled: true 9 | } 10 | end 11 | 12 | def yaml_without_rubocop_enabled 13 | %{ 14 | engines: 15 | rubocop: 16 | enabled: false 17 | } 18 | end 19 | 20 | def yaml_without_jshint 21 | %{ 22 | engines: 23 | rubocop: 24 | enabled: false 25 | } 26 | end 27 | 28 | def create_correct_yaml 29 | %{ 30 | engines: 31 | rubocop: 32 | enabled: true 33 | } 34 | end 35 | 36 | def create_yaml_with_errors 37 | %{ 38 | engkxhfgkxfhg: sdoufhsfogh: - 39 | 0- 40 | fgkjfhgkdjfg;h:; 41 | sligj: 42 | oi i ; 43 | } 44 | end 45 | 46 | def create_yaml_with_warning 47 | %{ 48 | engines: 49 | unknown_key: 50 | } 51 | end 52 | 53 | def create_yaml_with_nested_warning 54 | %{ 55 | engines: 56 | rubocop: 57 | } 58 | end 59 | 60 | def create_yaml_with_nested_and_unnested_warnings 61 | %{ 62 | engines: 63 | rubocop: 64 | enabled: true 65 | jshint: 66 | not_enabled 67 | strange_key: 68 | } 69 | end 70 | 71 | def create_yaml_with_no_engines 72 | %{ 73 | engines: 74 | } 75 | end 76 | 77 | def create_classic_yaml 78 | %{ 79 | languages: 80 | Ruby: true 81 | exclude_paths: 82 | - excluded.rb 83 | } 84 | end 85 | 86 | def sample_issue 87 | { 88 | "type" => "issue", 89 | "check" => "Rubocop/Style/Documentation", 90 | "description" => "Missing top-level class documentation comment.", 91 | "categories" => ["Style"], 92 | "remediation_points" => 10, 93 | "location"=> { 94 | "path" => "lib/cc/analyzer/config.rb", 95 | "lines" => { 96 | "begin" => 32, 97 | "end" => 40 98 | } 99 | } 100 | } 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /codeclimate-wrapper: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | invalid_setup() { 3 | local reason=$1 4 | 5 | cat >&2 < $reason 9 | 10 | We require a local Docker daemon that supports communication via the default 11 | socket path. 12 | 13 | Please use \`docker run' to run the \`codeclimate/codeclimate' image directly. 14 | 15 | See https://github.com/codeclimate/codeclimate for more details. 16 | 17 | EOF 18 | exit 1 19 | } 20 | 21 | socket_missing() { 22 | invalid_setup "/var/run/docker.sock must exist as a Unix domain socket" 23 | } 24 | 25 | invalid_docker_host() { 26 | local host=$1 27 | 28 | invalid_setup "invalid DOCKER_HOST=$host, must be unset or unix:///var/run/docker.sock" 29 | } 30 | 31 | if command -v docker-machine > /dev/null 2>&1; then 32 | docker-machine ssh $DOCKER_MACHINE_NAME -- \ 33 | test -S /var/run/docker.sock > /dev/null 2>&1 || socket_missing 34 | 35 | docker-machine ssh $DOCKER_MACHINE_NAME -- \ 36 | 'test -n "$DOCKER_HOST" -a "$DOCKER_HOST" != "unix:///var/run/docker.sock"' > /dev/null 2>&1 \ 37 | && invalid_docker_host $(boot2docker ssh -- 'echo "$DOCKER_HOST"') 38 | elif command -v boot2docker > /dev/null 2>&1; then 39 | boot2docker ssh -- \ 40 | test -S /var/run/docker.sock > /dev/null 2>&1 || socket_missing 41 | 42 | boot2docker ssh -- \ 43 | 'test -n "$DOCKER_HOST" -a "$DOCKER_HOST" != "unix:///var/run/docker.sock"' > /dev/null 2>&1 \ 44 | && invalid_docker_host $(boot2docker ssh -- 'echo "$DOCKER_HOST"') 45 | else 46 | test -S /var/run/docker.sock || socket_missing 47 | test -n "$DOCKER_HOST" -a "$DOCKER_HOST" != "unix:///var/run/docker.sock" \ 48 | && invalid_docker_host "$DOCKER_HOST" 49 | fi 50 | 51 | docker_run() { 52 | exec docker run \ 53 | --interactive --rm \ 54 | --env CODE_PATH="$PWD" \ 55 | --volume "$PWD":/code \ 56 | --volume /tmp/cc:/tmp/cc \ 57 | --volume /var/run/docker.sock:/var/run/docker.sock \ 58 | "$@" 59 | } 60 | 61 | if [ -t 1 ]; then 62 | docker_run --tty codeclimate/codeclimate "$@" 63 | else 64 | docker_run codeclimate/codeclimate "$@" 65 | fi 66 | -------------------------------------------------------------------------------- /spec/cc/cli/engines/remove_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::CLI::Engines 4 | describe Remove do 5 | describe "#run" do 6 | describe "when the engine requested does not exist in Code Climate registry" do 7 | it "says engine does not exist" do 8 | within_temp_dir do 9 | create_yaml 10 | filesystem.exist?(".codeclimate.yml").must_equal(true) 11 | 12 | stdout, stderr = capture_io do 13 | Remove.new(args = ["the_litte_engine_that_could"]).run 14 | end 15 | 16 | stdout.must_match("Engine not found. Run 'codeclimate engines:list' for a list of valid engines.") 17 | end 18 | end 19 | end 20 | 21 | describe "when engine to be removed is present in .codeclimate.yml" do 22 | it "reports that engine is removed" do 23 | within_temp_dir do 24 | create_yaml(Factory.yaml_with_rubocop_enabled) 25 | 26 | stdout, stderr = capture_io do 27 | Remove.new(args = ["rubocop"]).run 28 | end 29 | 30 | stdout.must_match("Engine removed from .codeclimate.yml.") 31 | end 32 | end 33 | 34 | it "removes engine from yaml file" do 35 | within_temp_dir do 36 | create_yaml(Factory.yaml_with_rubocop_enabled) 37 | 38 | stdout, stderr = capture_io do 39 | Remove.new(args = ["rubocop"]).run 40 | end 41 | 42 | content_after = File.read(".codeclimate.yml") 43 | 44 | stdout.must_match("Engine removed from .codeclimate.yml.") 45 | CC::Analyzer::Config.new(content_after).engine_present?("rubocop").must_equal(false) 46 | end 47 | end 48 | end 49 | end 50 | 51 | def filesystem 52 | @filesystem ||= CC::Analyzer::Filesystem.new(".") 53 | end 54 | 55 | def within_temp_dir(&block) 56 | temp = Dir.mktmpdir 57 | 58 | Dir.chdir(temp) do 59 | yield 60 | end 61 | end 62 | 63 | def create_yaml(yaml_content = Factory.create_correct_yaml) 64 | File.write(".codeclimate.yml", yaml_content) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/cc/analyzer/path_minimizer.rb: -------------------------------------------------------------------------------- 1 | require "cc/analyzer/path_entries" 2 | require "cc/analyzer/include_paths_builder" 3 | 4 | module CC 5 | module Analyzer 6 | class PathMinimizer 7 | def initialize(paths) 8 | @paths = paths 9 | @to_remove = [] 10 | end 11 | 12 | def minimize 13 | if diff.empty? 14 | ["./"] 15 | else 16 | filtered_paths 17 | end 18 | end 19 | 20 | private 21 | 22 | attr_reader :paths 23 | 24 | def diff 25 | @_diff ||= 26 | (all_files - paths). 27 | reject { |path| File.symlink?(path) }. 28 | flat_map { |path| build_entry_combinations(path) } 29 | end 30 | 31 | def filtered_paths 32 | filtered_paths = @paths - paths_to_remove 33 | filtered_paths.map { |path| add_trailing_slash(path) } 34 | end 35 | 36 | def paths_to_remove 37 | @paths.reduce([]) do |to_remove, path| 38 | if File.directory?(path) 39 | to_remove + removable_paths_for(path) 40 | else 41 | to_remove 42 | end 43 | end 44 | end 45 | 46 | def removable_paths_for(path) 47 | file_paths = PathEntries.new(path).entries 48 | 49 | if all_paths_match?(file_paths) 50 | file_paths - [path] 51 | else 52 | [path] 53 | end 54 | end 55 | 56 | def all_paths_match?(paths) 57 | paths.all? { |path| @paths.include?(path) } 58 | end 59 | 60 | def add_trailing_slash(path) 61 | if File.directory?(path) && !path.end_with?("/") 62 | "#{path}/" 63 | else 64 | path 65 | end 66 | end 67 | 68 | def build_entry_combinations(path) 69 | split = path.split("/") 70 | 71 | 0.upto(split.length - 1).map do |n| 72 | split[0..n].join("/") 73 | end 74 | end 75 | 76 | def all_files 77 | @_all_files ||= 78 | Dir.glob("*", File::FNM_DOTMATCH). 79 | reject { |path| IncludePathsBuilder::IGNORE_PATHS.include?(path) }. 80 | flat_map { |path| PathEntries.new(path).entries } 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/cc/cli/upgrade_config_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "cc/cli/upgrade_config_generator" 3 | 4 | module CC::CLI 5 | describe UpgradeConfigGenerator do 6 | include Factory 7 | include FileSystemHelpers 8 | 9 | around do |test| 10 | within_temp_dir { test.call } 11 | end 12 | 13 | describe "#can_generate?" do 14 | it "is true when existing config is valid" do 15 | File.write(".codeclimate.yml", create_classic_yaml) 16 | 17 | generator.can_generate?.must_equal true 18 | end 19 | 20 | it "is false when existing config is not valid" do 21 | File.write(".codeclimate.yml", %{ 22 | z%$:::::/ 23 | languages: 24 | Ruby: true 25 | exclude_paths: 26 | - excluded.rb 27 | }) 28 | 29 | generator.can_generate?.must_equal false 30 | end 31 | end 32 | 33 | describe "#eligible_engines" do 34 | it "calculates eligible_engines based on classic languages & source files" do 35 | File.write(".codeclimate.yml", create_classic_yaml) 36 | write_fixture_source_files 37 | 38 | expected_engine_names = %w(rubocop csslint fixme duplication) 39 | expected_engines = engine_registry.list.select do |name, _| 40 | expected_engine_names.include?(name) 41 | end 42 | generator.eligible_engines.must_equal expected_engines 43 | end 44 | end 45 | 46 | describe "#exclude_paths" do 47 | it "uses existing exclude_paths from yaml" do 48 | File.write(".codeclimate.yml", create_classic_yaml) 49 | 50 | expected_paths = %w(excluded.rb) 51 | generator.exclude_paths.must_equal expected_paths 52 | end 53 | 54 | it "uses existing exclude_paths from yaml when coerced from string" do 55 | File.write(".codeclimate.yml", %{ 56 | languages: 57 | Ruby: true 58 | exclude_paths: excluded.rb 59 | }) 60 | 61 | expected_paths = %w(excluded.rb) 62 | generator.exclude_paths.must_equal expected_paths 63 | end 64 | end 65 | 66 | def generator 67 | @generator ||= UpgradeConfigGenerator.new(make_filesystem, engine_registry) 68 | end 69 | 70 | def engine_registry 71 | @engine_registry ||= CC::Analyzer::EngineRegistry.new 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | codeclimate (0.16.4) 5 | activesupport (~> 4.2, >= 4.2.1) 6 | codeclimate-yaml (~> 0.6.1) 7 | faraday (~> 0.9.1) 8 | faraday_middleware (~> 0.9.1) 9 | highline (~> 1.7, >= 1.7.2) 10 | posix-spawn (~> 0.3, >= 0.3.11) 11 | pry (~> 0.10.1) 12 | rainbow (~> 2.0, >= 2.0.0) 13 | safe_yaml (~> 1.0, >= 1.0.4) 14 | tty-spinner (~> 0.1.0) 15 | 16 | GEM 17 | remote: https://rubygems.org/ 18 | specs: 19 | activesupport (4.2.5) 20 | i18n (~> 0.7) 21 | json (~> 1.7, >= 1.7.7) 22 | minitest (~> 5.1) 23 | thread_safe (~> 0.3, >= 0.3.4) 24 | tzinfo (~> 1.1) 25 | ansi (1.5.0) 26 | builder (3.2.2) 27 | codeclimate-test-reporter (0.4.8) 28 | simplecov (>= 0.7.1, < 1.0.0) 29 | codeclimate-yaml (0.6.1) 30 | activesupport 31 | secure_string 32 | coderay (1.1.0) 33 | docile (1.1.5) 34 | faraday (0.9.2) 35 | multipart-post (>= 1.2, < 3) 36 | faraday_middleware (0.9.2) 37 | faraday (>= 0.7.4, < 0.10) 38 | highline (1.7.8) 39 | i18n (0.7.0) 40 | json (1.8.3) 41 | metaclass (0.0.4) 42 | method_source (0.8.2) 43 | minitest (5.6.0) 44 | minitest-around (0.3.1) 45 | minitest (~> 5.0) 46 | minitest-reporters (1.0.11) 47 | ansi 48 | builder 49 | minitest (>= 5.0) 50 | ruby-progressbar 51 | mocha (1.1.0) 52 | metaclass (~> 0.0.1) 53 | multipart-post (2.0.0) 54 | posix-spawn (0.3.11) 55 | pry (0.10.3) 56 | coderay (~> 1.1.0) 57 | method_source (~> 0.8.1) 58 | slop (~> 3.4) 59 | rack (1.6.0) 60 | rack-test (0.6.3) 61 | rack (>= 1.0) 62 | rainbow (2.0.0) 63 | rake (10.4.2) 64 | ruby-progressbar (1.7.5) 65 | safe_yaml (1.0.4) 66 | secure_string (1.3.3) 67 | simplecov (0.10.0) 68 | docile (~> 1.1.0) 69 | json (~> 1.8) 70 | simplecov-html (~> 0.10.0) 71 | simplecov-html (0.10.0) 72 | slop (3.6.0) 73 | thread_safe (0.3.5) 74 | tty-spinner (0.1.0) 75 | tzinfo (1.2.2) 76 | thread_safe (~> 0.1) 77 | 78 | PLATFORMS 79 | ruby 80 | 81 | DEPENDENCIES 82 | codeclimate! 83 | codeclimate-test-reporter 84 | minitest 85 | minitest-around 86 | minitest-reporters 87 | mocha 88 | rack-test 89 | rake 90 | 91 | BUNDLED WITH 92 | 1.10.6 93 | -------------------------------------------------------------------------------- /spec/cc/cli/engines/disable_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::CLI::Engines 4 | describe Disable do 5 | describe "#run" do 6 | describe "when the engine requested does not exist in Code Climate registry" do 7 | it "says engine does not exist" do 8 | within_temp_dir do 9 | create_yaml 10 | filesystem.exist?(".codeclimate.yml").must_equal(true) 11 | 12 | stdout, stderr = capture_io do 13 | Disable.new(args = ["the_litte_engine_that_could"]).run 14 | end 15 | 16 | stdout.must_match("Engine not found. Run 'codeclimate engines:list' for a list of valid engines.") 17 | end 18 | end 19 | end 20 | 21 | describe "when engine is present in .codeclimate.yml and already disabled" do 22 | it "reports that engine is already disabled" do 23 | within_temp_dir do 24 | create_yaml(Factory.yaml_without_rubocop_enabled) 25 | 26 | stdout, stderr = capture_io do 27 | Disable.new(args = ["rubocop"]).run 28 | end 29 | 30 | stdout.must_match("Engine already disabled.") 31 | end 32 | end 33 | end 34 | 35 | describe "when engine is present in .codeclimate.yml and enabled" do 36 | it "disables engine in yaml file" do 37 | within_temp_dir do 38 | create_yaml(Factory.yaml_with_rubocop_enabled) 39 | content_before = File.read(".codeclimate.yml") 40 | 41 | stdout, stderr = capture_io do 42 | Disable.new(args = ["rubocop"]).run 43 | end 44 | 45 | content_after = File.read(".codeclimate.yml") 46 | 47 | stdout.must_match("Engine disabled.") 48 | CC::Analyzer::Config.new(content_before).engine_enabled?("rubocop").must_equal(true) 49 | CC::Analyzer::Config.new(content_after).engine_enabled?("rubocop").must_equal(false) 50 | end 51 | end 52 | end 53 | end 54 | 55 | def filesystem 56 | @filesystem ||= CC::Analyzer::Filesystem.new(".") 57 | end 58 | 59 | def within_temp_dir(&block) 60 | temp = Dir.mktmpdir 61 | 62 | Dir.chdir(temp) do 63 | yield 64 | end 65 | end 66 | 67 | def create_yaml(yaml_content = Factory.create_correct_yaml) 68 | File.write(".codeclimate.yml", yaml_content) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/cc/analyzer/engines_config_builder.rb: -------------------------------------------------------------------------------- 1 | require "securerandom" 2 | 3 | module CC 4 | module Analyzer 5 | class EnginesConfigBuilder 6 | Result = Struct.new( 7 | :name, 8 | :registry_entry, 9 | :code_path, 10 | :config, 11 | :container_label, 12 | ) 13 | 14 | def initialize(registry:, config:, container_label:, source_dir:, requested_paths:) 15 | @registry = registry 16 | @config = config 17 | @container_label = container_label 18 | @requested_paths = requested_paths 19 | @source_dir = source_dir 20 | end 21 | 22 | def run 23 | names_and_raw_engine_configs.map do |name, raw_engine_config| 24 | label = @container_label || SecureRandom.uuid 25 | engine_config = engine_config(raw_engine_config) 26 | Result.new(name, @registry[name], @source_dir, engine_config, label) 27 | end 28 | end 29 | 30 | private 31 | 32 | attr_reader :include_paths 33 | 34 | def engine_config(raw_engine_config) 35 | config = raw_engine_config.merge( 36 | exclude_paths: exclude_paths, 37 | include_paths: include_paths, 38 | ) 39 | # The yaml gem turns a config file string into a hash, but engines 40 | # expect the string. So we (for now) need to turn it into a string in 41 | # that one scenario. 42 | # TODO: update the engines to expect the hash and then remove this. 43 | if config.fetch("config", {}).keys.size == 1 && config["config"].key?("file") 44 | config["config"] = config["config"]["file"] 45 | end 46 | config 47 | end 48 | 49 | def names_and_raw_engine_configs 50 | {}.tap do |ret| 51 | (@config.engines || {}).each do |name, raw_engine_config| 52 | if raw_engine_config.enabled? && @registry.key?(name) 53 | ret[name] = raw_engine_config 54 | end 55 | end 56 | end 57 | end 58 | 59 | def include_paths 60 | IncludePathsBuilder.new(exclude_paths, Array(@requested_paths)).build 61 | end 62 | 63 | def exclude_paths 64 | PathPatterns.new(@config.exclude_paths || []).expanded + 65 | gitignore_paths 66 | end 67 | 68 | def gitignore_paths 69 | if File.exist?(".gitignore") 70 | `git ls-files --others -i -z --exclude-from .gitignore`.split("\0") 71 | else 72 | [] 73 | end 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/cc/analyzer/engine.rb: -------------------------------------------------------------------------------- 1 | require "securerandom" 2 | 3 | module CC 4 | module Analyzer 5 | class Engine 6 | EngineFailure = Class.new(StandardError) 7 | EngineTimeout = Class.new(StandardError) 8 | 9 | attr_reader :name 10 | 11 | DEFAULT_MEMORY_LIMIT = 512_000_000.freeze 12 | 13 | def initialize(name, metadata, code_path, config, label) 14 | @name = name 15 | @metadata = metadata 16 | @code_path = code_path 17 | @config = config 18 | @label = label.to_s 19 | end 20 | 21 | def run(stdout_io, container_listener) 22 | composite_listener = CompositeContainerListener.new( 23 | container_listener, 24 | LoggingContainerListener.new(name, Analyzer.logger), 25 | StatsdContainerListener.new(name, Analyzer.statsd), 26 | RaisingContainerListener.new(name, EngineFailure, EngineTimeout), 27 | ) 28 | 29 | container = Container.new( 30 | image: @metadata["image"], 31 | command: @metadata["command"], 32 | name: container_name, 33 | listener: composite_listener, 34 | ) 35 | 36 | container.on_output("\0") do |raw_output| 37 | output = EngineOutput.new(raw_output) 38 | 39 | unless output_filter.filter?(output) 40 | stdout_io.write(output.to_json) || container.stop 41 | end 42 | end 43 | 44 | container.run(container_options) 45 | end 46 | 47 | private 48 | 49 | def container_options 50 | [ 51 | "--cap-drop", "all", 52 | "--label", "com.codeclimate.label=#{@label}", 53 | "--memory", memory_limit, 54 | "--memory-swap", "-1", 55 | "--net", "none", 56 | "--volume", "#{@code_path}:/code:ro", 57 | "--volume", "#{config_file}:/config.json:ro", 58 | "--user", "9000:9000" 59 | ] 60 | end 61 | 62 | def container_name 63 | @container_name ||= "cc-engines-#{name}-#{SecureRandom.uuid}" 64 | end 65 | 66 | def config_file 67 | path = File.join("/tmp/cc", SecureRandom.uuid) 68 | FileUtils.mkdir_p("/tmp/cc") 69 | File.write(path, @config.to_json) 70 | path 71 | end 72 | 73 | def output_filter 74 | @output_filter ||= EngineOutputFilter.new(@config) 75 | end 76 | 77 | # Memory limit for a running engine in bytes 78 | def memory_limit 79 | (ENV["ENGINE_MEMORY_LIMIT_BYTES"] || DEFAULT_MEMORY_LIMIT).to_s 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/cc/cli/validate_config.rb: -------------------------------------------------------------------------------- 1 | require "cc/yaml" 2 | 3 | module CC 4 | module CLI 5 | class ValidateConfig < Command 6 | include CC::Analyzer 7 | include CC::Yaml 8 | 9 | VALID_CONFIG_MESSAGE = "No errors or warnings found in .codeclimate.yml file.".freeze 10 | 11 | def run 12 | require_codeclimate_yml 13 | verify_yaml 14 | end 15 | 16 | private 17 | 18 | def verify_yaml 19 | if any_issues? 20 | display_issues 21 | else 22 | puts colorize(VALID_CONFIG_MESSAGE, :green) 23 | end 24 | end 25 | 26 | def any_issues? 27 | parsed_yaml.errors? || parsed_yaml.nested_warnings.any? || parsed_yaml.warnings? || invalid_engines.any? 28 | end 29 | 30 | def yaml_content 31 | filesystem.read_path(CODECLIMATE_YAML).freeze 32 | end 33 | 34 | def parsed_yaml 35 | @parsed_yaml ||= CC::Yaml.parse(yaml_content) 36 | end 37 | 38 | def warnings 39 | @warnings ||= parsed_yaml.warnings 40 | end 41 | 42 | def nested_warnings 43 | @nested_warnings ||= parsed_yaml.nested_warnings 44 | end 45 | 46 | def errors 47 | @errors ||= parsed_yaml.errors 48 | end 49 | 50 | def display_issues 51 | display_errors 52 | display_warnings 53 | display_invalid_engines 54 | display_nested_warnings 55 | end 56 | 57 | def display_errors 58 | errors.each do |error| 59 | puts colorize("ERROR: #{error}", :red) 60 | end 61 | end 62 | 63 | def display_nested_warnings 64 | nested_warnings.each do |nested_warning| 65 | if nested_warning[0][0] 66 | puts colorize("WARNING in #{nested_warning[0][0]}: #{nested_warning[1]}", :red) 67 | end 68 | end 69 | end 70 | 71 | def display_warnings 72 | warnings.each do |warning| 73 | puts colorize("WARNING: #{warning}", :red) 74 | end 75 | end 76 | 77 | def display_invalid_engines 78 | invalid_engines.each do |engine_name| 79 | puts colorize("WARNING: unknown engine <#{engine_name}>", :red) 80 | end 81 | end 82 | 83 | def invalid_engines 84 | @invalid_engines ||= engine_names.reject { |engine_name| engine_registry.exists? engine_name } 85 | end 86 | 87 | def engine_names 88 | @engine_names ||= engines.keys 89 | end 90 | 91 | def engines 92 | @engines ||= parsed_yaml.engines || {} 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/cc/analyzer/config.rb: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | module CC 4 | module Analyzer 5 | # TODO: replace each use of this with CC::Yaml and remove it 6 | class Config 7 | def initialize(config_body) 8 | @config = YAML.safe_load(config_body) || { "engines" => {} } 9 | @config["engines"] ||= {} 10 | 11 | expand_shorthand 12 | end 13 | 14 | def to_hash 15 | @config 16 | end 17 | 18 | def engine_config(engine_name) 19 | @config["engines"][engine_name] || {} 20 | end 21 | 22 | def engine_names 23 | @config["engines"].keys.select { |name| engine_enabled?(name) } 24 | end 25 | 26 | def engine_present?(engine_name) 27 | @config["engines"][engine_name].present? 28 | end 29 | 30 | def engine_enabled?(engine_name) 31 | @config["engines"][engine_name] && @config["engines"][engine_name]["enabled"] 32 | end 33 | 34 | def enable_engine(engine_name) 35 | if engine_present?(engine_name) 36 | @config["engines"][engine_name]["enabled"] = true 37 | else 38 | @config["engines"][engine_name] = { "enabled" => true } 39 | enable_default_config(engine_name) if default_config(engine_name) 40 | end 41 | end 42 | 43 | def enable_default_config(engine_name) 44 | @config["engines"][engine_name]["config"] = default_config(engine_name) 45 | end 46 | 47 | def exclude_paths 48 | @config["exclude_paths"] 49 | end 50 | 51 | def disable_engine(engine_name) 52 | if engine_present?(engine_name) && engine_enabled?(engine_name) 53 | @config["engines"][engine_name]["enabled"] = false 54 | end 55 | end 56 | 57 | def remove_engine(engine_name) 58 | if engine_present?(engine_name) 59 | @config["engines"].delete(engine_name) 60 | end 61 | end 62 | 63 | def to_yaml 64 | @config.to_yaml 65 | end 66 | 67 | private 68 | 69 | def expand_shorthand 70 | @config["engines"].each do |name, engine_config| 71 | if [true, false].include?(engine_config) 72 | @config["engines"][name] = { "enabled" => engine_config } 73 | end 74 | end 75 | end 76 | 77 | def default_config(engine_name) 78 | if (engine_config = engine_registry[engine_name]) 79 | engine_config["default_config"] 80 | end 81 | end 82 | 83 | def engine_registry 84 | @engine_registry ||= CC::Analyzer::EngineRegistry.new 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /spec/cc/analyzer/engine_output_filter_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::Analyzer 4 | describe EngineOutputFilter do 5 | it "does not filter arbitrary json" do 6 | filter = EngineOutputFilter.new 7 | 8 | filter.filter?(EngineOutput.new(%{{"arbitrary":"json"}})).must_equal false 9 | end 10 | 11 | it "does not filter issues missing or enabled in the config" do 12 | foo_issue = build_issue("foo") 13 | bar_issue = build_issue("bar") 14 | 15 | filter = EngineOutputFilter.new( 16 | engine_config( 17 | "checks" => { 18 | "foo" => { "enabled" => true }, 19 | } 20 | ) 21 | ) 22 | 23 | filter.filter?(foo_issue).must_equal false 24 | filter.filter?(bar_issue).must_equal false 25 | end 26 | 27 | it "filters issues ignored in the config" do 28 | issue = build_issue("foo") 29 | 30 | filter = EngineOutputFilter.new( 31 | engine_config( 32 | "checks" => { 33 | "foo" => { "enabled" => false }, 34 | } 35 | ) 36 | ) 37 | 38 | filter.filter?(issue).must_equal true 39 | end 40 | 41 | it "filters issues ignored in the config even if the type has the wrong case" do 42 | issue = EngineOutput.new({ 43 | "type" => "Issue", "check_name" => "foo", 44 | }.to_json) 45 | 46 | filter = EngineOutputFilter.new( 47 | engine_config( 48 | "checks" => { 49 | "foo" => { "enabled" => false }, 50 | } 51 | ) 52 | ) 53 | 54 | filter.filter?(issue).must_equal true 55 | end 56 | 57 | it "filters issues with a fingerprint that matches exclude_fingerprints" do 58 | issue = EngineOutput.new({ 59 | "type" => "Issue", 60 | "check_name" => "foo", 61 | "fingerprint" => "05a33ac5659c1e90cad1ce32ff8a91c0" 62 | }.to_json) 63 | 64 | filter = EngineOutputFilter.new( 65 | engine_config( 66 | "exclude_fingerprints" => [ 67 | "05a33ac5659c1e90cad1ce32ff8a91c0" 68 | ] 69 | ) 70 | ) 71 | 72 | filter.filter?(issue).must_equal true 73 | end 74 | 75 | def build_issue(check_name) 76 | EngineOutput.new({ 77 | "type" => EngineOutputFilter::ISSUE_TYPE, 78 | "check_name" => check_name, 79 | }.to_json) 80 | end 81 | 82 | def engine_config(hash) 83 | codeclimate_yaml = { 84 | "engines" => { 85 | "rubocop" => hash.merge("enabled" => true) 86 | } 87 | }.to_yaml 88 | 89 | CC::Yaml.parse(codeclimate_yaml).engines["rubocop"] 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /spec/cc/analyzer/engines_runner_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "file_utils_ext" 3 | 4 | module CC::Analyzer 5 | describe EnginesRunner do 6 | include FileSystemHelpers 7 | 8 | around do |test| 9 | within_temp_dir { test.call } 10 | end 11 | 12 | before do 13 | system("git init > /dev/null") 14 | end 15 | 16 | it "builds and runs enabled engines from the registry with the formatter" do 17 | config = config_with_engine("an_engine") 18 | registry = registry_with_engine("an_engine") 19 | formatter = null_formatter 20 | 21 | expect_engine_run("an_engine", "/code", formatter) 22 | 23 | EnginesRunner.new(registry, formatter, "/code", config).run 24 | end 25 | 26 | it "raises for no enabled engines" do 27 | config = stub(engines: {}, exclude_paths: []) 28 | runner = EnginesRunner.new({}, null_formatter, "/code", config) 29 | 30 | lambda { runner.run }.must_raise(EnginesRunner::NoEnabledEngines) 31 | end 32 | 33 | describe "when the formatter does not respond to #close" do 34 | let(:config) { config_with_engine("an_engine") } 35 | let(:formatter) do 36 | formatter = stub(started: nil, write: nil, run: nil, finished: nil) 37 | formatter.stubs(:engine_running).yields 38 | formatter 39 | end 40 | let(:registry) { registry_with_engine("an_engine") } 41 | 42 | it "does not call #close" do 43 | expect_engine_run("an_engine", "/code", formatter) 44 | EnginesRunner.new(registry, formatter, "/code", config).run 45 | end 46 | end 47 | 48 | def registry_with_engine(name) 49 | { name => { "image" => "codeclimate/codeclimate-#{name}" } } 50 | end 51 | 52 | def config_with_engine(name) 53 | CC::Yaml.parse(<<-EOYAML) 54 | engines: 55 | #{name}: 56 | enabled: true 57 | EOYAML 58 | end 59 | 60 | def expect_engine_run(name, source_dir, formatter, engine_config = nil) 61 | engine = stub(name: name) 62 | engine.expects(:run). 63 | with(formatter, kind_of(ContainerListener)) 64 | 65 | image = "codeclimate/codeclimate-#{name}" 66 | engine_config ||= { 67 | "enabled" => true, 68 | exclude_paths: [], 69 | include_paths: ["./"] 70 | } 71 | 72 | Engine.expects(:new). 73 | with(name, { "image" => image }, source_dir, engine_config, anything). 74 | returns(engine) 75 | end 76 | 77 | def null_formatter 78 | formatter = stub(started: nil, write: nil, run: nil, finished: nil, close: nil) 79 | formatter.stubs(:engine_running).yields 80 | formatter 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/cc/analyzer/filesystem_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::Analyzer 4 | describe Filesystem do 5 | describe "#exist?" do 6 | it "returns true for files that exist" do 7 | root = Dir.mktmpdir 8 | File.write(File.join(root, "foo.rb"), "") 9 | 10 | filesystem = Filesystem.new(root) 11 | 12 | filesystem.exist?("foo.rb").must_equal(true) 13 | filesystem.exist?("bar.rb").must_equal(false) 14 | end 15 | end 16 | 17 | describe "#any?" do 18 | it "returns true if any files match any extensions" do 19 | root = Dir.mktmpdir 20 | File.write(File.join(root, "foo.rb"), "") 21 | File.write(File.join(root, "foo.js"), "") 22 | Dir.mkdir(File.join(root, "foo")) 23 | File.write(File.join(root, "foo", "foo.sh"), "") 24 | File.write(File.join(root, "foo", "bar.php"), "") 25 | 26 | filesystem = Filesystem.new(root) 27 | 28 | filesystem.any? { |p| /\.sh$/ =~ p }.must_equal(true) 29 | end 30 | 31 | it "returns false if no files match any extensions" do 32 | root = Dir.mktmpdir 33 | File.write(File.join(root, "foo.rb"), "") 34 | File.write(File.join(root, "foo.js"), "") 35 | Dir.mkdir(File.join(root, "foo")) 36 | File.write(File.join(root, "foo", "foo.sh"), "") 37 | File.write(File.join(root, "foo", "bar.php"), "") 38 | 39 | filesystem = Filesystem.new(root) 40 | 41 | filesystem.any? { |p| /\.hs$/ =~ p }.must_equal(false) 42 | end 43 | end 44 | 45 | describe "#files_matching" do 46 | it "returns files that match the globs" do 47 | root = Dir.mktmpdir 48 | File.write("#{root}/foo.js", "Foo") 49 | File.write("#{root}/foo.rb", "Foo") 50 | 51 | filesystem = Filesystem.new(root) 52 | 53 | filesystem.files_matching(["*.js"]).must_equal(["foo.js"]) 54 | end 55 | end 56 | 57 | describe "#read_path" do 58 | it "returns the content for the given file" do 59 | root = Dir.mktmpdir 60 | File.write(File.join(root, "foo.rb"), "Foo") 61 | File.write(File.join(root, "bar.rb"), "Bar") 62 | 63 | filesystem = Filesystem.new(root) 64 | 65 | filesystem.read_path("foo.rb").must_equal("Foo") 66 | filesystem.read_path("bar.rb").must_equal("Bar") 67 | end 68 | end 69 | 70 | describe "#write_path" do 71 | it "writes to the filesystem, given a path to a file and content" do 72 | filesystem = Filesystem.new(Dir.mktmpdir) 73 | 74 | filesystem.write_path("foo.js", "Hello world") 75 | 76 | filesystem.exist?("foo.js").must_equal(true) 77 | filesystem.read_path("foo.js").must_equal("Hello world") 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/cc/cli/analyze.rb: -------------------------------------------------------------------------------- 1 | module CC 2 | module CLI 3 | class Analyze < Command 4 | include CC::Analyzer 5 | 6 | def initialize(_args = []) 7 | super 8 | @engine_options = [] 9 | @path_options = [] 10 | 11 | process_args 12 | apply_config_options 13 | end 14 | 15 | def run 16 | require_codeclimate_yml 17 | 18 | Dir.chdir(ENV["FILESYSTEM_DIR"]) do 19 | runner = EnginesRunner.new(registry, formatter, source_dir, config, path_options) 20 | runner.run 21 | end 22 | 23 | rescue EnginesRunner::InvalidEngineName => ex 24 | fatal(ex.message) 25 | rescue EnginesRunner::NoEnabledEngines 26 | fatal("No enabled engines. Add some to your .codeclimate.yml file!") 27 | end 28 | 29 | private 30 | 31 | attr_accessor :config 32 | attr_reader :engine_options, :path_options 33 | 34 | def process_args 35 | while (arg = @args.shift) 36 | case arg 37 | when "-f" 38 | @formatter = Formatters.resolve(@args.shift).new(filesystem) 39 | when "-e", "--engine" 40 | @engine_options << @args.shift 41 | when "--dev" 42 | @dev_mode = true 43 | else 44 | @path_options << arg 45 | end 46 | end 47 | rescue Formatters::Formatter::InvalidFormatterError => e 48 | fatal(e.message) 49 | end 50 | 51 | def registry 52 | EngineRegistry.new(@dev_mode) 53 | end 54 | 55 | def formatter 56 | @formatter ||= Formatters::PlainTextFormatter.new(filesystem) 57 | end 58 | 59 | def source_dir 60 | ENV["CODE_PATH"] 61 | end 62 | 63 | def config 64 | @config ||= CC::Yaml.parse(filesystem.read_path(CODECLIMATE_YAML)) 65 | end 66 | 67 | def apply_config_options 68 | if engine_options.any? && config.engines? 69 | filter_by_engine_options 70 | elsif engine_options.any? 71 | config["engines"] = CC::Yaml::Nodes::EngineList.new(config).with_value({}) 72 | end 73 | add_engine_options 74 | end 75 | 76 | def filter_by_engine_options 77 | config.engines.keys.each do |engine| 78 | unless engine_options.include?(engine) 79 | config.engines.delete(engine) 80 | end 81 | end 82 | end 83 | 84 | def add_engine_options 85 | engine_options.each do |engine| 86 | if config.engines.include?(engine) 87 | config.engines[engine].enabled = true 88 | else 89 | config.engines[engine] = CC::Yaml::Nodes::Engine.new(config.engines).with_value("enabled" => true) 90 | end 91 | end 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/cc/cli/config_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "cc/cli/config_generator" 3 | require "cc/cli/upgrade_config_generator" 4 | 5 | module CC::CLI 6 | describe ConfigGenerator do 7 | include Factory 8 | include FileSystemHelpers 9 | 10 | around do |test| 11 | within_temp_dir { test.call } 12 | end 13 | 14 | describe "self.for" do 15 | it "returns a standard generator when upgrade not requested" do 16 | generator = ConfigGenerator.for(make_filesystem, engine_registry, false) 17 | generator.class.must_equal ConfigGenerator 18 | end 19 | 20 | it "returns a standard generator when upgrade requested but .codeclimate.yml does not exist" do 21 | generator = ConfigGenerator.for(make_filesystem, engine_registry, true) 22 | generator.class.must_equal ConfigGenerator 23 | end 24 | 25 | it "returns an upgrade generator when requested" do 26 | File.write(".codeclimate.yml", create_classic_yaml) 27 | generator = ConfigGenerator.for(make_filesystem, engine_registry, true) 28 | generator.class.must_equal UpgradeConfigGenerator 29 | end 30 | end 31 | 32 | describe "#can_generate?" do 33 | it "is true" do 34 | generator.can_generate?.must_equal true 35 | end 36 | end 37 | 38 | describe "#eligible_engines" do 39 | it "calculates eligible_engines based on existing files" do 40 | write_fixture_source_files 41 | 42 | expected_engine_names = %w(rubocop eslint csslint fixme duplication) 43 | expected_engines = engine_registry.list.select do |name, _| 44 | expected_engine_names.include?(name) 45 | end 46 | generator.eligible_engines.must_equal expected_engines 47 | end 48 | 49 | it "returns brakeman when Gemfile.lock exists" do 50 | File.write("Gemfile.lock", "gemfile-lock-content") 51 | 52 | expected_engine_names = %w(bundler-audit fixme) 53 | expected_engines = engine_registry.list.select do |name, _| 54 | expected_engine_names.include?(name) 55 | end 56 | generator.eligible_engines.must_equal expected_engines 57 | end 58 | end 59 | 60 | describe "#errors" do 61 | it "is empty array" do 62 | generator.errors.must_equal [] 63 | end 64 | end 65 | 66 | describe "#exclude_paths" do 67 | it "uses AUTO_EXCLUDE_PATHS that exist locally" do 68 | write_fixture_source_files 69 | 70 | expected_paths = %w(config/ spec/ vendor/) 71 | generator.exclude_paths.must_equal expected_paths 72 | end 73 | end 74 | 75 | def generator 76 | @generator ||= ConfigGenerator.new(make_filesystem, engine_registry) 77 | end 78 | 79 | def engine_registry 80 | @engine_registry ||= CC::Analyzer::EngineRegistry.new 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/cc/analyzer/formatters/json_formatter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module CC::Analyzer::Formatters 4 | describe JSONFormatter do 5 | include Factory 6 | 7 | let(:formatter) do 8 | filesystem ||= CC::Analyzer::Filesystem.new(ENV['FILESYSTEM_DIR']) 9 | JSONFormatter.new(filesystem) 10 | end 11 | 12 | describe "#start, write, finished" do 13 | it "outputs a string that can be parsed as JSON" do 14 | issue1 = sample_issue 15 | issue2 = sample_issue 16 | 17 | stdout, stderr = capture_io do 18 | formatter.started 19 | formatter.engine_running(engine_double("cool_engine")) do 20 | formatter.write(issue1.to_json) 21 | formatter.write(issue2.to_json) 22 | end 23 | formatter.finished 24 | end 25 | 26 | parsed_json = JSON.parse(stdout) 27 | parsed_json.must_equal([{"type"=>"issue", "check"=>"Rubocop/Style/Documentation", "description"=>"Missing top-level class documentation comment.", "categories"=>["Style"], "remediation_points"=>10, "location"=>{"path"=>"lib/cc/analyzer/config.rb", "lines"=>{"begin"=>32, "end"=>40}}, "engine_name"=>"cool_engine"}, {"type"=>"issue", "check"=>"Rubocop/Style/Documentation", "description"=>"Missing top-level class documentation comment.", "categories"=>["Style"], "remediation_points"=>10, "location"=>{"path"=>"lib/cc/analyzer/config.rb", "lines"=>{"begin"=>32, "end"=>40}}, "engine_name"=>"cool_engine"}]) 28 | end 29 | 30 | it "prints a correctly formatted array of comma separated JSON issues" do 31 | issue1 = sample_issue 32 | issue2 = sample_issue 33 | 34 | stdout, stderr = capture_io do 35 | formatter.started 36 | formatter.engine_running(engine_double("cool_engine")) do 37 | formatter.write(issue1.to_json) 38 | formatter.write(issue2.to_json) 39 | end 40 | formatter.finished 41 | end 42 | 43 | last_two_characters = stdout[stdout.length-2..stdout.length-1] 44 | 45 | stdout.first.must_match("[") 46 | last_two_characters.must_match("]\n") 47 | 48 | stdout.must_equal("[{\"type\":\"issue\",\"check\":\"Rubocop/Style/Documentation\",\"description\":\"Missing top-level class documentation comment.\",\"categories\":[\"Style\"],\"remediation_points\":10,\"location\":{\"path\":\"lib/cc/analyzer/config.rb\",\"lines\":{\"begin\":32,\"end\":40}},\"engine_name\":\"cool_engine\"},\n{\"type\":\"issue\",\"check\":\"Rubocop/Style/Documentation\",\"description\":\"Missing top-level class documentation comment.\",\"categories\":[\"Style\"],\"remediation_points\":10,\"location\":{\"path\":\"lib/cc/analyzer/config.rb\",\"lines\":{\"begin\":32,\"end\":40}},\"engine_name\":\"cool_engine\"}]\n") 49 | end 50 | end 51 | 52 | def engine_double(name) 53 | stub(name: name) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /config/coffeelint/coffeelint.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrow_spacing": { 3 | "level": "ignore" 4 | }, 5 | "braces_spacing": { 6 | "level": "ignore", 7 | "spaces": 0, 8 | "empty_object_spaces": 0 9 | }, 10 | "camel_case_classes": { 11 | "level": "error" 12 | }, 13 | "coffeescript_error": { 14 | "level": "error" 15 | }, 16 | "colon_assignment_spacing": { 17 | "level": "ignore", 18 | "spacing": { 19 | "left": 0, 20 | "right": 0 21 | } 22 | }, 23 | "cyclomatic_complexity": { 24 | "value": 10, 25 | "level": "ignore" 26 | }, 27 | "duplicate_key": { 28 | "level": "error" 29 | }, 30 | "empty_constructor_needs_parens": { 31 | "level": "ignore" 32 | }, 33 | "ensure_comprehensions": { 34 | "level": "warn" 35 | }, 36 | "eol_last": { 37 | "level": "ignore" 38 | }, 39 | "indentation": { 40 | "value": 2, 41 | "level": "error" 42 | }, 43 | "line_endings": { 44 | "level": "ignore", 45 | "value": "unix" 46 | }, 47 | "max_line_length": { 48 | "value": 80, 49 | "level": "error", 50 | "limitComments": true 51 | }, 52 | "missing_fat_arrows": { 53 | "level": "ignore", 54 | "is_strict": false 55 | }, 56 | "newlines_after_classes": { 57 | "value": 3, 58 | "level": "ignore" 59 | }, 60 | "no_backticks": { 61 | "level": "error" 62 | }, 63 | "no_debugger": { 64 | "level": "warn", 65 | "console": false 66 | }, 67 | "no_empty_functions": { 68 | "level": "ignore" 69 | }, 70 | "no_empty_param_list": { 71 | "level": "ignore" 72 | }, 73 | "no_implicit_braces": { 74 | "level": "ignore", 75 | "strict": true 76 | }, 77 | "no_implicit_parens": { 78 | "strict": true, 79 | "level": "ignore" 80 | }, 81 | "no_interpolation_in_single_quotes": { 82 | "level": "ignore" 83 | }, 84 | "no_plusplus": { 85 | "level": "ignore" 86 | }, 87 | "no_stand_alone_at": { 88 | "level": "ignore" 89 | }, 90 | "no_tabs": { 91 | "level": "error" 92 | }, 93 | "no_this": { 94 | "level": "ignore" 95 | }, 96 | "no_throwing_strings": { 97 | "level": "error" 98 | }, 99 | "no_trailing_semicolons": { 100 | "level": "error" 101 | }, 102 | "no_trailing_whitespace": { 103 | "level": "error", 104 | "allowed_in_comments": false, 105 | "allowed_in_empty_lines": true 106 | }, 107 | "no_unnecessary_double_quotes": { 108 | "level": "ignore" 109 | }, 110 | "no_unnecessary_fat_arrows": { 111 | "level": "warn" 112 | }, 113 | "non_empty_constructor_needs_parens": { 114 | "level": "ignore" 115 | }, 116 | "prefer_english_operator": { 117 | "level": "ignore", 118 | "doubleNotLevel": "ignore" 119 | }, 120 | "space_operators": { 121 | "level": "ignore" 122 | }, 123 | "spacing_after_comma": { 124 | "level": "ignore" 125 | }, 126 | "transform_messes_up_line_numbers": { 127 | "level": "warn" 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /spec/cc/analyzer/config_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "cc/analyzer" 3 | 4 | 5 | describe CC::Analyzer::Config do 6 | describe "#engine_present?(engine_name)" do 7 | describe "when the given engine is not present in yaml file" do 8 | it "returns false" do 9 | parsed_yaml = CC::Analyzer::Config.new(Factory.yaml_without_jshint) 10 | 11 | parsed_yaml.engine_present?("jshint").must_equal(false) 12 | end 13 | end 14 | 15 | describe "when the engine is present in yaml file" do 16 | it "returns false" do 17 | parsed_yaml = CC::Analyzer::Config.new(Factory.yaml_with_rubocop_enabled) 18 | 19 | parsed_yaml.engine_present?("rubocop").must_equal(true) 20 | end 21 | end 22 | end 23 | 24 | describe "#engine_names" do 25 | it "returns only enabled engines" do 26 | yaml = %{ 27 | engines: 28 | rubocop: 29 | enabled: false 30 | curses: 31 | enabled: true 32 | } 33 | config = CC::Analyzer::Config.new(yaml) 34 | config.engine_names.must_equal(["curses"]) 35 | end 36 | end 37 | 38 | describe "#engine_config" do 39 | it "returns the config" do 40 | config = CC::Analyzer::Config.new(Factory.yaml_with_rubocop_enabled) 41 | config.engine_config("rubocop").must_equal({"enabled" => true}) 42 | end 43 | 44 | it "returns an empty hash" do 45 | config = CC::Analyzer::Config.new(Factory.yaml_with_rubocop_enabled) 46 | config.engine_config("bugfixer").must_equal({}) 47 | end 48 | end 49 | 50 | describe "#engine_enabled?(engine_name)" do 51 | describe "when the engine is enabled" do 52 | it "returns true" do 53 | parsed_yaml = CC::Analyzer::Config.new(Factory.yaml_with_rubocop_enabled) 54 | 55 | parsed_yaml.engine_enabled?("rubocop").must_equal(true) 56 | end 57 | end 58 | 59 | describe "when the engine is not enabled" do 60 | it "returns false" do 61 | parsed_yaml = CC::Analyzer::Config.new(Factory.yaml_without_rubocop_enabled) 62 | 63 | parsed_yaml.engine_enabled?("rubocop").must_equal(false) 64 | end 65 | end 66 | end 67 | 68 | describe "#enable_engine(engine_name)" do 69 | describe "when the engine is present but unabled" do 70 | it "enables engine" do 71 | parsed_yaml = CC::Analyzer::Config.new(Factory.yaml_without_rubocop_enabled) 72 | parsed_yaml.engine_enabled?("rubocop").must_equal(false) 73 | 74 | parsed_yaml.enable_engine("rubocop") 75 | 76 | parsed_yaml.engine_enabled?("rubocop").must_equal(true) 77 | end 78 | end 79 | 80 | describe "when the engine is not present" do 81 | it "adds engine to list of engines and enables it" do 82 | parsed_yaml = CC::Analyzer::Config.new(Factory.yaml_without_jshint) 83 | parsed_yaml.engine_present?("jshint").must_equal(false) 84 | 85 | parsed_yaml.enable_engine("jshint") 86 | 87 | parsed_yaml.engine_enabled?("jshint").must_equal(true) 88 | end 89 | end 90 | end 91 | end 92 | 93 | -------------------------------------------------------------------------------- /spec/cc/analyzer/issue_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::Analyzer 4 | describe Issue do 5 | it "allows access to keys as methods" do 6 | output = { 7 | "categories" => ["Style"], 8 | "check_name" => "Rubocop/Style/Documentation", 9 | "description" => "Missing top-level class documentation comment.", 10 | "location"=> { 11 | "lines" => { 12 | "begin" => 32, 13 | "end" => 40, 14 | }, 15 | "path" => "lib/cc/analyzer/config.rb", 16 | }, 17 | "remediation_points" => 10, 18 | "type" => "issue", 19 | }.to_json 20 | issue = Issue.new(output) 21 | 22 | issue.must_respond_to("check_name") 23 | issue.check_name.must_equal("Rubocop/Style/Documentation") 24 | end 25 | 26 | describe "#fingerprint" do 27 | it "adds a fingerprint when it is missing" do 28 | output = { 29 | "categories" => ["Style"], 30 | "check_name" => "Rubocop/Style/Documentation", 31 | "description" => "Missing top-level class documentation comment.", 32 | "location"=> { 33 | "lines" => { 34 | "begin" => 32, 35 | "end" => 40, 36 | }, 37 | "path" => "lib/cc/analyzer/config.rb", 38 | }, 39 | "remediation_points" => 10, 40 | "type" => "issue", 41 | }.to_json 42 | issue = Issue.new(output) 43 | 44 | issue.fingerprint.must_equal "9d20301efe0bbb8f87fb4eb15a71fc81" 45 | end 46 | 47 | it "doesn't overwrite fingerprints within output" do 48 | output = { 49 | "categories" => ["Style"], 50 | "check_name" => "Rubocop/Style/Documentation", 51 | "description" => "Missing top-level class documentation comment.", 52 | "fingerprint" => "foo", 53 | "location"=> { 54 | "lines" => { 55 | "begin" => 32, 56 | "end" => 40, 57 | }, 58 | "path" => "lib/cc/analyzer/config.rb", 59 | }, 60 | "remediation_points" => 10, 61 | "type" => "issue", 62 | }.to_json 63 | issue = Issue.new(output) 64 | 65 | issue.fingerprint.must_equal "foo" 66 | end 67 | end 68 | 69 | describe "#as_json" do 70 | it "merges in defaulted attributes" do 71 | output = { 72 | "categories" => ["Style"], 73 | "check_name" => "Rubocop/Style/Documentation", 74 | "description" => "Missing top-level class documentation comment.", 75 | "location"=> { 76 | "lines" => { 77 | "begin" => 32, 78 | "end" => 40, 79 | }, 80 | "path" => "lib/cc/analyzer/config.rb", 81 | }, 82 | "remediation_points" => 10, 83 | "type" => "issue", 84 | } 85 | expected_additions = { 86 | "fingerprint" => "9d20301efe0bbb8f87fb4eb15a71fc81", 87 | } 88 | issue = Issue.new(output.to_json) 89 | 90 | issue.as_json.must_equal(output.merge(expected_additions)) 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # Metrics 3 | ################################################################################ 4 | 5 | Metrics/LineLength: 6 | Enabled: false 7 | 8 | Metrics/AbcSize: 9 | Enabled: false 10 | 11 | ################################################################################ 12 | # Lint 13 | ################################################################################ 14 | 15 | Lint/Debugger: 16 | Exclude: 17 | - lib/cc/cli/console.rb 18 | 19 | ################################################################################ 20 | # Style 21 | ################################################################################ 22 | 23 | # Executables are conventionally named bin/foo-bar 24 | Style/FileName: 25 | Exclude: 26 | - bin/**/* 27 | 28 | # We don't (currently) document our code 29 | Style/Documentation: 30 | Enabled: false 31 | 32 | # Always use double-quotes to keep things simple 33 | Style/StringLiterals: 34 | EnforcedStyle: double_quotes 35 | 36 | # Use a trailing comma to keep diffs clean when elements are inserted or removed 37 | Style/TrailingComma: 38 | EnforcedStyleForMultiline: comma 39 | 40 | # We avoid GuardClause because it can result in "suprise return" 41 | Style/GuardClause: 42 | Enabled: false 43 | 44 | # We avoid IfUnlessModifier because it can result in "suprise if" 45 | Style/IfUnlessModifier: 46 | Enabled: false 47 | 48 | # We don't care about the fail/raise distinction 49 | Style/SignalException: 50 | EnforcedStyle: only_raise 51 | 52 | Style/DotPosition: 53 | EnforcedStyle: trailing 54 | 55 | # Common globals we allow 56 | Style/GlobalVars: 57 | AllowedVariables: 58 | - "$statsd" 59 | - "$mongo" 60 | - "$rollout" 61 | 62 | # Allow $! in config/initializers 63 | Style/SpecialGlobalVars: 64 | Exclude: 65 | - config/initializers/**/* 66 | 67 | # We have common cases where has_ and have_ make sense 68 | Style/PredicateName: 69 | Enabled: true 70 | NamePrefixBlacklist: 71 | - is_ 72 | 73 | # We use %w[ ], not %w( ) because the former looks like an array 74 | Style/PercentLiteralDelimiters: 75 | PreferredDelimiters: 76 | "%w": [] 77 | "%W": [] 78 | 79 | ################################################################################ 80 | # Rails - disable things because we're primarily non-rails 81 | ################################################################################ 82 | 83 | Rails/Delegate: 84 | Enabled: false 85 | 86 | Rails/TimeZone: 87 | Enabled: false 88 | 89 | ################################################################################ 90 | # Specs - be more lenient on length checks and block styles 91 | ################################################################################ 92 | 93 | Metrics/ModuleLength: 94 | Exclude: 95 | - spec/**/* 96 | 97 | Metrics/MethodLength: 98 | Exclude: 99 | - spec/**/* 100 | 101 | Style/ClassAndModuleChildren: 102 | Exclude: 103 | - spec/**/* 104 | 105 | Style/BlockDelimiters: 106 | Exclude: 107 | - spec/**/* 108 | 109 | Style/Blocks: 110 | Exclude: 111 | - spec/**/* 112 | -------------------------------------------------------------------------------- /lib/cc/cli/init.rb: -------------------------------------------------------------------------------- 1 | require "cc/cli/config" 2 | require "cc/cli/config_generator" 3 | require "cc/cli/upgrade_config_generator" 4 | 5 | module CC 6 | module CLI 7 | class Init < Command 8 | include CC::Analyzer 9 | 10 | def run 11 | if !upgrade? && filesystem.exist?(CODECLIMATE_YAML) 12 | warn "Config file .codeclimate.yml already present.\nTry running 'validate-config' to check configuration." 13 | create_default_configs 14 | elsif upgrade? && engines_enabled? 15 | fatal "--upgrade should not be used on a .codeclimate.yml configured for the Platform.\nTry running 'validate-config' to check configuration." 16 | else 17 | generate_config 18 | end 19 | end 20 | 21 | private 22 | 23 | def upgrade? 24 | @args.include?("--upgrade") 25 | end 26 | 27 | def generate_config 28 | unless config_generator.can_generate? 29 | config_generator.errors.each do |error| 30 | $stderr.puts colorize("ERROR: #{error}", :red) 31 | end 32 | fatal "Cannot generate .codeclimate.yml: please address above errors." 33 | end 34 | 35 | create_codeclimate_yaml 36 | success "Config file .codeclimate.yml successfully #{config_generator.post_generation_verb}.\nEdit and then try running 'validate-config' to check configuration." 37 | create_default_configs 38 | end 39 | 40 | def create_codeclimate_yaml 41 | config = CC::CLI::Config.new 42 | 43 | config_generator.eligible_engines.each do |(engine_name, engine_config)| 44 | config.add_engine(engine_name, engine_config) 45 | end 46 | 47 | config.add_exclude_paths(config_generator.exclude_paths) 48 | filesystem.write_path(CODECLIMATE_YAML, config.to_yaml) 49 | end 50 | 51 | def create_default_configs 52 | available_configs.each do |config_path| 53 | file_name = File.basename(config_path) 54 | if filesystem.exist?(file_name) 55 | say "Skipping generating #{file_name} file (already exists)." 56 | else 57 | filesystem.write_path(file_name, File.read(config_path)) 58 | success "Config file #{file_name} successfully generated." 59 | end 60 | end 61 | end 62 | 63 | def available_configs 64 | all_paths = config_generator.eligible_engines.flat_map do |engine_name, _| 65 | engine_directory = File.expand_path("../../../../config/#{engine_name}", __FILE__) 66 | Dir.glob("#{engine_directory}/*", File::FNM_DOTMATCH) 67 | end 68 | 69 | all_paths.reject { |path| [".", ".."].include?(File.basename(path)) } 70 | end 71 | 72 | def engines_enabled? 73 | unless @engines_enabled.nil? 74 | return @engines_enabled 75 | end 76 | 77 | if filesystem.exist?(CODECLIMATE_YAML) 78 | config = CC::Analyzer::Config.new(File.read(CODECLIMATE_YAML)) 79 | @engines_enabled ||= config.engine_names.any? 80 | end 81 | end 82 | 83 | def config_generator 84 | @config_generator ||= ConfigGenerator.for(filesystem, engine_registry, upgrade?) 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/cc/analyzer/formatters/plain_text_formatter.rb: -------------------------------------------------------------------------------- 1 | require "rainbow" 2 | require "tty/spinner" 3 | require "active_support/number_helper" 4 | 5 | module CC 6 | module Analyzer 7 | module Formatters 8 | class PlainTextFormatter < Formatter 9 | def started 10 | puts colorize("Starting analysis", :green) 11 | end 12 | 13 | def write(data) 14 | json = JSON.parse(data) 15 | json["engine_name"] = current_engine.name 16 | 17 | case json["type"].downcase 18 | when "issue" 19 | issues << json 20 | when "warning" 21 | warnings << json 22 | else 23 | raise "Invalid type found: #{json['type']}" 24 | end 25 | end 26 | 27 | def finished 28 | puts 29 | 30 | issues_by_path.each do |path, file_issues| 31 | puts colorize("== #{path} (#{pluralize(file_issues.size, 'issue')}) ==", :yellow) 32 | 33 | IssueSorter.new(file_issues).by_location.each do |issue| 34 | if (location = issue["location"]) 35 | source_buffer = @filesystem.source_buffer_for(location["path"]) 36 | print(colorize(LocationDescription.new(source_buffer, location, ": "), :cyan)) 37 | end 38 | 39 | print(issue["description"]) 40 | print(colorize(" [#{issue['engine_name']}]", "#333333")) 41 | puts 42 | end 43 | puts 44 | end 45 | 46 | print(colorize("Analysis complete! Found #{pluralize(issues.size, 'issue')}", :green)) 47 | if warnings.size > 0 48 | print(colorize(" and #{pluralize(warnings.size, 'warning')}", :green)) 49 | end 50 | puts(colorize(".", :green)) 51 | end 52 | 53 | def engine_running(engine, &block) 54 | super(engine) do 55 | with_spinner("Running #{current_engine.name}: ", &block) 56 | end 57 | end 58 | 59 | def failed(output) 60 | spinner.stop("Failed") 61 | puts colorize("\nAnalysis failed with the following output:", :red) 62 | puts output 63 | exit 1 64 | end 65 | 66 | private 67 | 68 | def spinner(text = nil) 69 | @spinner ||= Spinner.new(text) 70 | end 71 | 72 | def with_spinner(text) 73 | spinner(text).start 74 | yield 75 | ensure 76 | spinner.stop 77 | @spinner = nil 78 | end 79 | 80 | def colorize(string, *args) 81 | rainbow.wrap(string).color(*args) 82 | end 83 | 84 | def rainbow 85 | @rainbow ||= Rainbow.new.tap do |rainbow| 86 | rainbow.enabled = false unless @output.tty? 87 | end 88 | end 89 | 90 | def issues 91 | @issues ||= [] 92 | end 93 | 94 | def issues_by_path 95 | issues.group_by { |i| i["location"]["path"] }.sort 96 | end 97 | 98 | def warnings 99 | @warnings ||= [] 100 | end 101 | 102 | def pluralize(number, noun) 103 | "#{ActiveSupport::NumberHelper.number_to_delimited(number)} #{noun.pluralize(number)}" 104 | end 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/cc/analyzer/engine_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::Analyzer 4 | describe Engine do 5 | before do 6 | FileUtils.mkdir_p("/tmp/cc") 7 | end 8 | 9 | describe "#run" do 10 | it "passes the correct options to Container" do 11 | container = stub 12 | container.stubs(:on_output).yields("") 13 | container.stubs(:run) 14 | 15 | Container.expects(:new).with do |args| 16 | args[:image].must_equal "codeclimate/foo" 17 | args[:command].must_equal "bar" 18 | args[:name].must_match /^cc-engines-foo/ 19 | end.returns(container) 20 | 21 | metadata = { "image" => "codeclimate/foo", "command" => "bar" } 22 | engine = Engine.new("foo", metadata, "", {}, "") 23 | engine.run(StringIO.new, ContainerListener.new) 24 | end 25 | 26 | it "runs a Container in a constrained environment" do 27 | container = stub 28 | container.stubs(:on_output).yields("") 29 | 30 | container.expects(:run).with(includes( 31 | "--cap-drop", "all", 32 | "--label", "com.codeclimate.label=a-label", 33 | "--memory", "512000000", 34 | "--memory-swap", "-1", 35 | "--net", "none", 36 | "--volume", "/code:/code:ro", 37 | "--user", "9000:9000", 38 | )) 39 | 40 | Container.expects(:new).returns(container) 41 | engine = Engine.new("", {}, "/code", {}, "a-label") 42 | engine.run(StringIO.new, ContainerListener.new) 43 | end 44 | 45 | it "passes a composite container listener wrapping the given one" do 46 | container = stub 47 | container.stubs(:on_output).yields("") 48 | container.stubs(:run) 49 | 50 | given_listener = stub 51 | container_listener = stub 52 | CompositeContainerListener.expects(:new). 53 | with( 54 | given_listener, 55 | kind_of(LoggingContainerListener), 56 | kind_of(StatsdContainerListener), 57 | kind_of(RaisingContainerListener), 58 | ). 59 | returns(container_listener) 60 | Container.expects(:new). 61 | with(has_entry(listener: container_listener)).returns(container) 62 | 63 | engine = Engine.new("", {}, "", {}, "") 64 | engine.run(StringIO.new, given_listener) 65 | end 66 | 67 | it "parses stdout for null-delimited issues" do 68 | container = TestContainer.new([ 69 | "{}", 70 | "{}", 71 | "{}", 72 | ]) 73 | Container.expects(:new).returns(container) 74 | 75 | stdout = StringIO.new 76 | engine = Engine.new("", {}, "", {}, "") 77 | engine.run(stdout, ContainerListener.new) 78 | 79 | stdout.string.must_equal "{\"fingerprint\":\"b99834bc19bbad24580b3adfa04fb947\"}{\"fingerprint\":\"b99834bc19bbad24580b3adfa04fb947\"}{\"fingerprint\":\"b99834bc19bbad24580b3adfa04fb947\"}" 80 | end 81 | 82 | it "supports issue filtering by check name" do 83 | container = TestContainer.new([ 84 | %{{"type":"issue","check_name":"foo"}}, 85 | %{{"type":"issue","check_name":"bar"}}, 86 | %{{"type":"issue","check_name":"baz"}}, 87 | ]) 88 | Container.expects(:new).returns(container) 89 | 90 | stdout = StringIO.new 91 | config = { "checks" => { "bar" => { "enabled" => false } } } 92 | engine = Engine.new("", {}, "", config, "") 93 | engine.run(stdout, ContainerListener.new) 94 | 95 | stdout.string.wont_match(%{"check":"bar"}) 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/cc/cli/engines/enable_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::CLI::Engines 4 | describe Enable do 5 | describe "#run" do 6 | before { Install.any_instance.stubs(:run) } 7 | 8 | describe "when the engine requested does not exist" do 9 | it "says engine does not exist" do 10 | within_temp_dir do 11 | create_yaml 12 | filesystem.exist?(".codeclimate.yml").must_equal(true) 13 | 14 | stdout, stderr = capture_io do 15 | Enable.new(args = ["the_litte_engine_that_could"]).run 16 | end 17 | 18 | stdout.must_match("Engine not found. Run 'codeclimate engines:list' for a list of valid engines.") 19 | end 20 | end 21 | end 22 | describe "when engine is already enabled" do 23 | it "reports that engine is enabled, doesn't change .codeclimate.yml" do 24 | within_temp_dir do 25 | create_yaml(Factory.yaml_with_rubocop_enabled) 26 | 27 | stdout, stderr = capture_io do 28 | Enable.new(args = ["rubocop"]).run 29 | end 30 | 31 | content_after = File.read(".codeclimate.yml") 32 | 33 | stdout.must_match("Engine already enabled.") 34 | content_after.must_equal(Factory.yaml_with_rubocop_enabled) 35 | end 36 | end 37 | end 38 | describe "when engine is in registry, but not enabled" do 39 | it "enables engine in yaml file" do 40 | within_temp_dir do 41 | create_yaml(Factory.yaml_without_rubocop_enabled) 42 | 43 | stdout, stderr = capture_io do 44 | Enable.new(args = ["rubocop"]).run 45 | end 46 | 47 | content_after = File.read(".codeclimate.yml") 48 | 49 | stdout.must_match("Engine added") 50 | CC::Analyzer::Config.new(content_after).engine_enabled?("rubocop").must_equal(true) 51 | end 52 | end 53 | end 54 | 55 | describe "when engine has a default configuration" do 56 | it "it includes the config when enabling an engine" do 57 | within_temp_dir do 58 | create_yaml(Factory.create_yaml_with_no_engines) 59 | 60 | stdout, stderr = capture_io do 61 | Enable.new(args = ["duplication"]).run 62 | end 63 | 64 | content_after = File.read(".codeclimate.yml") 65 | 66 | stdout.must_match("Engine added") 67 | config = CC::Analyzer::Config.new(content_after).engine_config("duplication") 68 | config["config"].must_equal("languages" => %w[ruby javascript python php]) 69 | end 70 | end 71 | end 72 | 73 | describe "when engine has no default configuration" do 74 | it "it omits the config key entirely" do 75 | within_temp_dir do 76 | create_yaml(Factory.create_yaml_with_no_engines) 77 | 78 | stdout, stderr = capture_io do 79 | Enable.new(args = ["coffeelint"]).run 80 | end 81 | 82 | content_after = File.read(".codeclimate.yml") 83 | 84 | config = CC::Analyzer::Config.new(content_after).engine_config("coffeelint") 85 | 86 | config.must_equal({"enabled" => true}) 87 | end 88 | end 89 | end 90 | end 91 | 92 | def filesystem 93 | @filesystem ||= CC::Analyzer::Filesystem.new(".") 94 | end 95 | 96 | def within_temp_dir(&block) 97 | temp = Dir.mktmpdir 98 | 99 | Dir.chdir(temp) do 100 | yield 101 | end 102 | end 103 | 104 | def create_yaml(yaml_content = Factory.create_correct_yaml) 105 | File.write(".codeclimate.yml", yaml_content) 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/cc/cli/analyze_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::CLI 4 | describe Analyze do 5 | describe "#run" do 6 | before { CC::Analyzer::Engine.any_instance.stubs(:run) } 7 | 8 | describe "when no engines are specified" do 9 | it "exits and reports no engines are enabled" do 10 | within_temp_dir do 11 | create_yaml(Factory.create_yaml_with_no_engines) 12 | 13 | _, stderr = capture_io do 14 | lambda { Analyze.new.run }.must_raise SystemExit 15 | end 16 | 17 | stderr.must_match("No enabled engines. Add some to your .codeclimate.yml file!") 18 | end 19 | end 20 | end 21 | 22 | describe "when engine is not in registry" do 23 | it "ignores engine, without blowing up" do 24 | within_temp_dir do 25 | create_yaml(<<-EOYAML) 26 | engines: 27 | madeup: 28 | enabled: true 29 | rubocop: 30 | enabled: true 31 | EOYAML 32 | 33 | _, stderr = capture_io do 34 | Analyze.new.run 35 | end 36 | 37 | stderr.must_match("") 38 | end 39 | end 40 | end 41 | 42 | describe "when user passes engine options to command" do 43 | it "uses only the engines provided" do 44 | within_temp_dir do 45 | create_yaml(<<-EOYAML) 46 | engines: 47 | rubocop: 48 | enabled: true 49 | EOYAML 50 | 51 | args = ["-e", "eslint"] 52 | 53 | analyze = Analyze.new(args) 54 | qualified_config = analyze.send(:config) 55 | 56 | qualified_config.engines.must_equal("eslint" => { "enabled" => true }) 57 | end 58 | end 59 | end 60 | 61 | describe "when user passes path args to command" do 62 | it "captures the paths provided as path_options" do 63 | within_temp_dir do 64 | create_yaml(<<-EOYAML) 65 | engines: 66 | rubocop: 67 | enabled: true 68 | eslint: 69 | enabled: true 70 | EOYAML 71 | 72 | args = ["-e", "eslint", "foo.rb"] 73 | paths = ["foo.rb"] 74 | 75 | analyze = Analyze.new(args) 76 | 77 | analyze.send(:path_options).must_equal(paths) 78 | end 79 | end 80 | end 81 | 82 | describe "when user passes path args to command" do 83 | it "passes the paths provided" do 84 | within_temp_dir do 85 | create_yaml(<<-EOYAML) 86 | engines: 87 | rubocop: 88 | enabled: true 89 | eslint: 90 | enabled: true 91 | EOYAML 92 | 93 | args = ["-e", "eslint", "foo.rb"] 94 | paths = ["foo.rb"] 95 | 96 | analyze = Analyze.new(args) 97 | engines_runner = stub(run: "peace") 98 | 99 | CC::Analyzer::EnginesRunner.expects(:new).with(anything, anything, anything, anything, paths).returns(engines_runner) 100 | 101 | analyze.run 102 | end 103 | end 104 | end 105 | 106 | describe "when a formatter argument is passed" do 107 | it "instantiates the correct formatter with a proper Filesystem argument" do 108 | CC::Analyzer::Formatters::JSONFormatter.expects(:new). 109 | with(kind_of(CC::Analyzer::Filesystem)) 110 | 111 | Analyze.new(%w[-f json]) 112 | end 113 | end 114 | end 115 | 116 | def within_temp_dir(&block) 117 | Dir.chdir(Dir.mktmpdir, &block) 118 | end 119 | 120 | def create_yaml(yaml_content = Factory.create_correct_yaml) 121 | File.write(".codeclimate.yml", yaml_content) 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Code Climate CLI
2 | 3 | [![Code Climate](https://codeclimate.com/github/codeclimate/codeclimate/badges/gpa.svg)](https://codeclimate.com/github/codeclimate/codeclimate) 4 | 5 | ## Overview 6 | 7 | `codeclimate` is a command line interface for the Code Climate analysis 8 | platform. It allows you to run Code Climate engines on your local machine inside 9 | of Docker containers. 10 | 11 | ## Prerequisites 12 | 13 | The Code Climate CLI is distributed and run as a 14 | [Docker](https://www.docker.com) image. The engines that perform the actual 15 | analyses are also Docker images. To support this, you must have Docker installed 16 | and running locally. We also require that the Docker daemon supports connections 17 | on the default Unix socket `/var/run/docker.sock`. 18 | 19 | On OS X, we recommend using [Docker Machine](https://docs.docker.com/machine/). 20 | 21 | ## Installation 22 | 23 | ```console 24 | docker pull codeclimate/codeclimate 25 | ``` 26 | 27 | ## Usage 28 | 29 | ```console 30 | docker run \ 31 | --interactive --tty --rm \ 32 | --env CODE_PATH="$PWD" \ 33 | --volume "$PWD":/code \ 34 | --volume /var/run/docker.sock:/var/run/docker.sock \ 35 | --volume /tmp/cc:/tmp/cc \ 36 | codeclimate/codeclimate help 37 | ``` 38 | 39 | ## Packages 40 | 41 | The above is very transparent. It's clear what's happening, and any changes 42 | required to work with your specific Docker setup can be discovered easily. That 43 | said, it can be unwieldy to invoke such a command on a regular basis. 44 | 45 | For this reason, we also provide packages that include a small wrapper script 46 | for the above invocation: 47 | 48 | ### OS X 49 | 50 | ```console 51 | brew tap codeclimate/formulae 52 | brew install codeclimate 53 | ``` 54 | 55 | To update the brew package, use `brew update` first: 56 | 57 | ```console 58 | brew update 59 | brew upgrade codeclimate 60 | ``` 61 | 62 | ### Anywhere 63 | 64 | ```console 65 | curl -L https://github.com/codeclimate/codeclimate/archive/master.tar.gz | tar xvz 66 | cd codeclimate-* && sudo make install 67 | ``` 68 | 69 | ## Commands 70 | 71 | A list of available commands is accessible by running `codeclimate` or 72 | `codeclimate help`. 73 | 74 | ```console 75 | $ codeclimate help 76 | 77 | Available commands: 78 | analyze [-f format] 79 | console 80 | engines:disable engine_name 81 | engines:enable engine_name 82 | engines:install 83 | engines:list 84 | engines:remove 85 | help 86 | init 87 | validate-config 88 | version 89 | ``` 90 | 91 | The following is a brief explanation of each available command. 92 | 93 | * `analyze`: Analyze all relevant files in the current working directory. All engines that are enabled in your `.codeclimate.yml` file will run, one after another. The `-f` (or `format`) argument allows you to set the output format of the analysis (using `json` or `text`). 94 | * `console`: start an interactive session providing access to the classes within the CLI. Useful for engine developers and maintainers. 95 | * `engines:disable engine_name`: Changes the engine's `enabled:` node to be `false` in your `.codeclimate.yml` file. This engine will not be run the next time your project is analyzed. 96 | * `engines:enable engine_name`: Installs the specified engine (`engine_name`). Also changes the engine's `enabled:` node to be `true` in your `.codeclimate.yml` file. This engine will be run the next time your project is analyzed. 97 | * `engines:install`: Compares the list of engines in your `.codeclimate.yml` file to those that are currently installed, then installs any missing engines. 98 | * `engines:list`: Lists all available engines in the [Code Climate Docker Hub](https://hub.docker.com/u/codeclimate/). 99 | * `engines:remove engine_name`: Removes an engine from your `.codeclimate.yml` file. 100 | * `help`: Displays a list of commands that can be passed to the Code Climate CLI. 101 | * `init`: Generates a new `.codeclimate.yml` file in the current working directory. 102 | * `validate-config`: Validates the `.codeclimate.yml` file in the current working directory. 103 | * `version`: Displays the current version of the Code Climate CLI. 104 | 105 | ## Copyright 106 | 107 | See [LICENSE](LICENSE) 108 | -------------------------------------------------------------------------------- /spec/cc/cli/validate_config_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::CLI 4 | describe ValidateConfig do 5 | describe "#run" do 6 | describe "when a .codeclimate.yml file is present in working directory" do 7 | it "analyzes the .codeclimate.yml file without altering it" do 8 | within_temp_dir do 9 | filesystem.exist?(".codeclimate.yml").must_equal(false) 10 | 11 | yaml_content_before = "This is a test yaml!" 12 | File.write(".codeclimate.yml", yaml_content_before) 13 | 14 | filesystem.exist?(".codeclimate.yml").must_equal(true) 15 | 16 | capture_io do 17 | validate_config = ValidateConfig.new 18 | validate_config.run 19 | end 20 | 21 | filesystem.exist?(".codeclimate.yml").must_equal(true) 22 | 23 | content_after = File.read(".codeclimate.yml") 24 | 25 | content_after.must_equal(yaml_content_before) 26 | end 27 | end 28 | 29 | describe "when there are errors present" do 30 | it "reports that an error was found" do 31 | within_temp_dir do 32 | yaml_content = Factory.create_yaml_with_errors 33 | File.write(".codeclimate.yml", yaml_content) 34 | 35 | stdout, stderr = capture_io do 36 | ValidateConfig.new.run 37 | end 38 | 39 | stdout.must_match("ERROR") 40 | end 41 | end 42 | end 43 | 44 | describe "when there are warnings present" do 45 | it "reports that a warning was found" do 46 | within_temp_dir do 47 | yaml_content = Factory.create_yaml_with_warning 48 | File.open(".codeclimate.yml", "w") do |f| 49 | f.write(yaml_content) 50 | end 51 | 52 | stdout, stderr = capture_io do 53 | ValidateConfig.new.run 54 | end 55 | 56 | stdout.must_match("WARNING:") 57 | end 58 | end 59 | end 60 | 61 | describe "when there are nested warnings present" do 62 | it "reports that a warning was found in the parent item" do 63 | within_temp_dir do 64 | yaml_content = Factory.create_yaml_with_nested_warning 65 | File.write(".codeclimate.yml", yaml_content) 66 | 67 | stdout, stderr = capture_io do 68 | ValidateConfig.new.run 69 | end 70 | 71 | stdout.must_match("ERROR: invalid \"engines\" section") 72 | end 73 | end 74 | end 75 | 76 | describe "when there are both regular and nested warnings present" do 77 | it "reports both kinds of warnings" do 78 | within_temp_dir do 79 | yaml_content = Factory.create_yaml_with_nested_and_unnested_warnings 80 | File.write(".codeclimate.yml", yaml_content) 81 | 82 | stdout, stderr = capture_io do 83 | ValidateConfig.new.run 84 | end 85 | 86 | stdout.must_match("ERROR: invalid \"engines\" section") 87 | end 88 | end 89 | end 90 | 91 | describe "when the present yaml is valid" do 92 | it "reports copy looks great" do 93 | within_temp_dir do 94 | yaml_content = Factory.create_correct_yaml 95 | File.write(".codeclimate.yml", yaml_content) 96 | 97 | stdout, stderr = capture_io do 98 | ValidateConfig.new.run 99 | end 100 | 101 | stdout.must_match("No errors or warnings found in .codeclimate.yml file.") 102 | end 103 | end 104 | end 105 | 106 | describe "when there are invalid engines" do 107 | it "reports that those engines are invalid" do 108 | within_temp_dir do 109 | yaml_content = <<-YAML 110 | engines: 111 | rubocop: 112 | enabled: true 113 | madeup: 114 | enabled: true 115 | ratings: 116 | paths: 117 | - "**/*.rb" 118 | - "**/*.js" 119 | YAML 120 | 121 | File.write(".codeclimate.yml", yaml_content) 122 | 123 | stdout, stderr = capture_io do 124 | ValidateConfig.new.run 125 | end 126 | 127 | stdout.must_include("WARNING: unknown engine ") 128 | end 129 | end 130 | end 131 | end 132 | end 133 | 134 | def filesystem 135 | @filesystem ||= CC::Analyzer::Filesystem.new(".") 136 | end 137 | 138 | def within_temp_dir(&block) 139 | temp = Dir.mktmpdir 140 | 141 | Dir.chdir(temp) do 142 | yield 143 | end 144 | end 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /config/eslint/.eslintrc: -------------------------------------------------------------------------------- 1 | ecmaFeatures: 2 | modules: true 3 | jsx: true 4 | 5 | env: 6 | amd: true 7 | browser: true 8 | es6: true 9 | jquery: true 10 | node: true 11 | 12 | # http://eslint.org/docs/rules/ 13 | rules: 14 | # Possible Errors 15 | comma-dangle: [2, never] 16 | no-cond-assign: 2 17 | no-console: 0 18 | no-constant-condition: 2 19 | no-control-regex: 2 20 | no-debugger: 2 21 | no-dupe-args: 2 22 | no-dupe-keys: 2 23 | no-duplicate-case: 2 24 | no-empty: 2 25 | no-empty-character-class: 2 26 | no-ex-assign: 2 27 | no-extra-boolean-cast: 2 28 | no-extra-parens: 0 29 | no-extra-semi: 2 30 | no-func-assign: 2 31 | no-inner-declarations: [2, functions] 32 | no-invalid-regexp: 2 33 | no-irregular-whitespace: 2 34 | no-negated-in-lhs: 2 35 | no-obj-calls: 2 36 | no-regex-spaces: 2 37 | no-sparse-arrays: 2 38 | no-unexpected-multiline: 2 39 | no-unreachable: 2 40 | use-isnan: 2 41 | valid-jsdoc: 0 42 | valid-typeof: 2 43 | 44 | # Best Practices 45 | accessor-pairs: 2 46 | block-scoped-var: 0 47 | complexity: [2, 11] 48 | consistent-return: 0 49 | curly: 0 50 | default-case: 0 51 | dot-location: 0 52 | dot-notation: 0 53 | eqeqeq: 2 54 | guard-for-in: 2 55 | no-alert: 2 56 | no-caller: 2 57 | no-case-declarations: 2 58 | no-div-regex: 2 59 | no-else-return: 0 60 | no-empty-label: 2 61 | no-empty-pattern: 2 62 | no-eq-null: 2 63 | no-eval: 2 64 | no-extend-native: 2 65 | no-extra-bind: 2 66 | no-fallthrough: 2 67 | no-floating-decimal: 0 68 | no-implicit-coercion: 0 69 | no-implied-eval: 2 70 | no-invalid-this: 0 71 | no-iterator: 2 72 | no-labels: 0 73 | no-lone-blocks: 2 74 | no-loop-func: 2 75 | no-magic-number: 0 76 | no-multi-spaces: 0 77 | no-multi-str: 0 78 | no-native-reassign: 2 79 | no-new-func: 2 80 | no-new-wrappers: 2 81 | no-new: 2 82 | no-octal-escape: 2 83 | no-octal: 2 84 | no-proto: 2 85 | no-redeclare: 2 86 | no-return-assign: 2 87 | no-script-url: 2 88 | no-self-compare: 2 89 | no-sequences: 0 90 | no-throw-literal: 0 91 | no-unused-expressions: 2 92 | no-useless-call: 2 93 | no-useless-concat: 2 94 | no-void: 2 95 | no-warning-comments: 0 96 | no-with: 2 97 | radix: 2 98 | vars-on-top: 0 99 | wrap-iife: 2 100 | yoda: 0 101 | 102 | # Strict 103 | strict: 0 104 | 105 | # Variables 106 | init-declarations: 0 107 | no-catch-shadow: 2 108 | no-delete-var: 2 109 | no-label-var: 2 110 | no-shadow-restricted-names: 2 111 | no-shadow: 0 112 | no-undef-init: 2 113 | no-undef: 0 114 | no-undefined: 0 115 | no-unused-vars: 0 116 | no-use-before-define: 0 117 | 118 | # Node.js and CommonJS 119 | callback-return: 2 120 | global-require: 2 121 | handle-callback-err: 2 122 | no-mixed-requires: 0 123 | no-new-require: 0 124 | no-path-concat: 2 125 | no-process-exit: 2 126 | no-restricted-modules: 0 127 | no-sync: 0 128 | 129 | # Stylistic Issues 130 | array-bracket-spacing: 0 131 | block-spacing: 0 132 | brace-style: 0 133 | camelcase: 0 134 | comma-spacing: 0 135 | comma-style: 0 136 | computed-property-spacing: 0 137 | consistent-this: 0 138 | eol-last: 0 139 | func-names: 0 140 | func-style: 0 141 | id-length: 0 142 | id-match: 0 143 | indent: 0 144 | jsx-quotes: 0 145 | key-spacing: 0 146 | linebreak-style: 0 147 | lines-around-comment: 0 148 | max-depth: 0 149 | max-len: 0 150 | max-nested-callbacks: 0 151 | max-params: 0 152 | max-statements: 0 153 | new-cap: 0 154 | new-parens: 0 155 | newline-after-var: 0 156 | no-array-constructor: 0 157 | no-bitwise: 0 158 | no-continue: 0 159 | no-inline-comments: 0 160 | no-lonely-if: 0 161 | no-mixed-spaces-and-tabs: 0 162 | no-multiple-empty-lines: 0 163 | no-negated-condition: 0 164 | no-nested-ternary: 0 165 | no-new-object: 0 166 | no-plusplus: 0 167 | no-restricted-syntax: 0 168 | no-spaced-func: 0 169 | no-ternary: 0 170 | no-trailing-spaces: 0 171 | no-underscore-dangle: 0 172 | no-unneeded-ternary: 0 173 | object-curly-spacing: 0 174 | one-var: 0 175 | operator-assignment: 0 176 | operator-linebreak: 0 177 | padded-blocks: 0 178 | quote-props: 0 179 | quotes: 0 180 | require-jsdoc: 0 181 | semi-spacing: 0 182 | semi: 0 183 | sort-vars: 0 184 | space-after-keywords: 0 185 | space-before-blocks: 0 186 | space-before-function-paren: 0 187 | space-before-keywords: 0 188 | space-in-parens: 0 189 | space-infix-ops: 0 190 | space-return-throw-case: 0 191 | space-unary-ops: 0 192 | spaced-comment: 0 193 | wrap-regex: 0 194 | 195 | # ECMAScript 6 196 | arrow-body-style: 0 197 | arrow-parens: 0 198 | arrow-spacing: 0 199 | constructor-super: 0 200 | generator-star-spacing: 0 201 | no-arrow-condition: 0 202 | no-class-assign: 0 203 | no-const-assign: 0 204 | no-dupe-class-members: 0 205 | no-this-before-super: 0 206 | no-var: 0 207 | object-shorthand: 0 208 | prefer-arrow-callback: 0 209 | prefer-const: 0 210 | prefer-reflect: 0 211 | prefer-spread: 0 212 | prefer-template: 0 213 | require-yield: 0 214 | -------------------------------------------------------------------------------- /spec/cc/analyzer/engines_config_builder_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "file_utils_ext" 3 | require "cc/analyzer" 4 | require "cc/analyzer/include_paths_builder" 5 | require "cc/analyzer/path_patterns" 6 | 7 | module CC::Analyzer 8 | describe EnginesConfigBuilder do 9 | include FileSystemHelpers 10 | 11 | let(:engines_config_builder) do 12 | EnginesConfigBuilder.new( 13 | registry: registry, 14 | config: config, 15 | container_label: container_label, 16 | source_dir: source_dir, 17 | requested_paths: requested_paths 18 | ) 19 | end 20 | let(:container_label) { nil } 21 | let(:requested_paths) { [] } 22 | let(:source_dir) { "/code" } 23 | 24 | around do |test| 25 | within_temp_dir { test.call } 26 | end 27 | 28 | before do 29 | system("git init > /dev/null") 30 | end 31 | 32 | describe "with one engine" do 33 | let(:config) { config_with_engine("an_engine") } 34 | let(:registry) { registry_with_engine("an_engine") } 35 | 36 | it "contains that engine" do 37 | result = engines_config_builder.run 38 | result.size.must_equal(1) 39 | result.first.name.must_equal("an_engine") 40 | end 41 | end 42 | 43 | describe "with an invalid engine name" do 44 | let(:config) { config_with_engine("an_engine") } 45 | let(:registry) { {} } 46 | 47 | it "does not raise" do 48 | engines_config_builder.run 49 | end 50 | end 51 | 52 | describe "with engine-specific config" do 53 | let(:config) do 54 | CC::Yaml.parse <<-EOYAML 55 | engines: 56 | rubocop: 57 | enabled: true 58 | config: 59 | file: rubocop.yml 60 | EOYAML 61 | end 62 | let(:registry) { registry_with_engine("rubocop") } 63 | 64 | it "keeps that config and adds some entries" do 65 | expected_config = { 66 | "enabled" => true, 67 | "config" => "rubocop.yml", 68 | :exclude_paths => [], 69 | :include_paths => ["./"] 70 | } 71 | result = engines_config_builder.run 72 | result.size.must_equal(1) 73 | result.first.name.must_equal("rubocop") 74 | result.first.registry_entry.must_equal(registry["rubocop"]) 75 | result.first.code_path.must_equal(source_dir) 76 | (result.first.config == expected_config).must_equal(true) 77 | result.first.container_label.wont_equal nil 78 | end 79 | end 80 | 81 | describe "with a .gitignore file" do 82 | let(:config) do 83 | CC::Yaml.parse <<-EOYAML 84 | engines: 85 | rubocop: 86 | enabled: true 87 | EOYAML 88 | end 89 | let(:registry) { registry_with_engine("rubocop") } 90 | 91 | before do 92 | make_file(".ignorethis") 93 | make_file(".gitignore", ".ignorethis\n") 94 | end 95 | 96 | before do 97 | FileUtils.stubs(:readable_by_all?).at_least_once.returns(true) 98 | end 99 | 100 | it "respects those paths" do 101 | expected_config = { 102 | "enabled" => true, 103 | :exclude_paths => %w(.ignorethis), 104 | :include_paths => %w(.gitignore) 105 | } 106 | result = engines_config_builder.run 107 | result.size.must_equal(1) 108 | result.first.name.must_equal("rubocop") 109 | result.first.registry_entry.must_equal(registry["rubocop"]) 110 | result.first.code_path.must_equal(source_dir) 111 | (result.first.config == expected_config).must_equal(true) 112 | result.first.container_label.wont_equal nil 113 | end 114 | end 115 | 116 | describe "when the source directory contains all readable files, and there are no ignored files" do 117 | let(:config) { config_with_engine("an_engine") } 118 | let(:registry) { registry_with_engine("an_engine") } 119 | 120 | before do 121 | make_file("root_file.rb") 122 | make_file("subdir/subdir_file.rb") 123 | end 124 | 125 | it "gets include_paths from IncludePathBuilder" do 126 | IncludePathsBuilder.stubs(:new).with([], []).returns(mock(build: ['.'])) 127 | expected_config = { 128 | "enabled" => true, 129 | :exclude_paths => [], 130 | :include_paths => ['.'] 131 | } 132 | result = engines_config_builder.run 133 | result.size.must_equal(1) 134 | result.first.name.must_equal("an_engine") 135 | result.first.registry_entry.must_equal(registry["an_engine"]) 136 | result.first.code_path.must_equal(source_dir) 137 | (result.first.config == expected_config).must_equal(true) 138 | result.first.container_label.wont_equal nil 139 | end 140 | end 141 | 142 | def registry_with_engine(*names) 143 | {}.tap do |result| 144 | names.each do |name| 145 | result[name] = { "image" => "codeclimate/codeclimate-#{name}" } 146 | end 147 | end 148 | end 149 | 150 | def config_with_engine(*names) 151 | raw = "engines:\n" 152 | names.each do |name| 153 | raw << " #{name}:\n enabled: true\n" 154 | end 155 | CC::Yaml.parse(raw) 156 | end 157 | 158 | def null_formatter 159 | formatter = stub(started: nil, write: nil, run: nil, finished: nil, close: nil) 160 | formatter.stubs(:engine_running).yields 161 | formatter 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/cc/analyzer/container.rb: -------------------------------------------------------------------------------- 1 | require "posix/spawn" 2 | require "thread" 3 | 4 | module CC 5 | module Analyzer 6 | class Container 7 | ContainerData = Struct.new( 8 | :image, # image used to create the container 9 | :name, # name given to the container when created 10 | :duration, # duration, for a finished event 11 | :status, # status, for a finished event 12 | :stderr, # stderr, for a finished event 13 | ) 14 | ImageRequired = Class.new(StandardError) 15 | Result = Struct.new( 16 | :exit_status, 17 | :timed_out?, 18 | :duration, 19 | :maximum_output_exceeded?, 20 | :output_byte_count, 21 | :stderr, 22 | ) 23 | 24 | DEFAULT_TIMEOUT = 15 * 60 # 15m 25 | DEFAULT_MAXIMUM_OUTPUT_BYTES = 500_000_000 26 | 27 | def initialize(image:, name:, command: nil, listener: ContainerListener.new) 28 | raise ImageRequired if image.blank? 29 | @image = image 30 | @name = name 31 | @command = command 32 | @listener = listener 33 | @output_delimeter = "\n" 34 | @on_output = ->(*) {} 35 | @timed_out = false 36 | @maximum_output_exceeded = false 37 | @stderr_io = StringIO.new 38 | @output_byte_count = 0 39 | @counter_mutex = Mutex.new 40 | end 41 | 42 | def on_output(delimeter = "\n", &block) 43 | @output_delimeter = delimeter 44 | @on_output = block 45 | end 46 | 47 | def run(options = []) 48 | started = Time.now 49 | @listener.started(container_data) 50 | 51 | pid, _, out, err = POSIX::Spawn.popen4(*docker_run_command(options)) 52 | 53 | @t_out = read_stdout(out) 54 | @t_err = read_stderr(err) 55 | t_timeout = timeout_thread 56 | 57 | # blocks until the engine stops. there may still be stdout in flight if 58 | # it was being produced more quickly than consumed. 59 | _, status = Process.waitpid2(pid) 60 | 61 | # blocks until all readers are done. they're still governed by the 62 | # timeout thread at this point. if we hit the timeout while processing 63 | # output, the threads will be Thread#killed as part of #stop and this 64 | # will unblock with the correct value in @timed_out 65 | [@t_out, @t_err].each(&:join) 66 | 67 | if @timed_out 68 | duration = timeout * 1000 69 | @listener.timed_out(container_data(duration: duration)) 70 | else 71 | duration = ((Time.now - started) * 1000).round 72 | @listener.finished(container_data(duration: duration, status: status)) 73 | end 74 | 75 | Result.new( 76 | status.exitstatus, 77 | @timed_out, 78 | duration, 79 | @maximum_output_exceeded, 80 | output_byte_count, 81 | @stderr_io.string, 82 | ) 83 | ensure 84 | kill_reader_threads 85 | t_timeout.kill if t_timeout 86 | end 87 | 88 | def stop(message = nil) 89 | reap_running_container(message) 90 | kill_reader_threads 91 | end 92 | 93 | private 94 | 95 | attr_reader :output_byte_count, :counter_mutex 96 | 97 | def docker_run_command(options) 98 | [ 99 | "docker", "run", 100 | "--rm", 101 | "--name", @name, 102 | options, 103 | @image, 104 | @command 105 | ].flatten.compact 106 | end 107 | 108 | def read_stdout(out) 109 | Thread.new do 110 | begin 111 | out.each_line(@output_delimeter) do |chunk| 112 | output = chunk.chomp(@output_delimeter) 113 | 114 | @on_output.call(output) 115 | check_output_bytes(output.bytesize) 116 | end 117 | ensure 118 | out.close 119 | end 120 | end 121 | end 122 | 123 | def read_stderr(err) 124 | Thread.new do 125 | begin 126 | err.each_line do |line| 127 | @stderr_io.write(line) 128 | check_output_bytes(line.bytesize) 129 | end 130 | ensure 131 | err.close 132 | end 133 | end 134 | end 135 | 136 | def timeout_thread 137 | Thread.new do 138 | # Doing one long `sleep timeout` seems to fail sometimes, so 139 | # we do a series of short timeouts before exiting 140 | start_time = Time.now 141 | loop do 142 | sleep 10 143 | duration = Time.now - start_time 144 | break if duration >= timeout 145 | end 146 | 147 | @timed_out = true 148 | stop("timed out") 149 | end.run 150 | end 151 | 152 | def check_output_bytes(last_read_byte_count) 153 | counter_mutex.synchronize do 154 | @output_byte_count += last_read_byte_count 155 | end 156 | 157 | if output_byte_count > maximum_output_bytes 158 | @maximum_output_exceeded = true 159 | stop("maximum output exceeded") 160 | end 161 | end 162 | 163 | def container_data(duration: nil, status: nil) 164 | ContainerData.new(@image, @name, duration, status, @stderr_io.string) 165 | end 166 | 167 | def kill_reader_threads 168 | @t_out.kill if @t_out 169 | @t_err.kill if @t_err 170 | end 171 | 172 | def reap_running_container(message) 173 | Analyzer.logger.warn("killing container name=#{@name} message=#{message.inspect}") 174 | POSIX::Spawn::Child.new("docker", "kill", @name) 175 | end 176 | 177 | def timeout 178 | ENV.fetch("CONTAINER_TIMEOUT_SECONDS", DEFAULT_TIMEOUT).to_i 179 | end 180 | 181 | def maximum_output_bytes 182 | ENV.fetch("CONTAINER_MAXIMUM_OUTPUT_BYTES", DEFAULT_MAXIMUM_OUTPUT_BYTES).to_i 183 | end 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /config/engines.yml: -------------------------------------------------------------------------------- 1 | # This file lists all the engines available to be run for analysis. 2 | # 3 | # Each engine must have an `image` and `description`. The value in `image` will 4 | # be passed to `docker run` and so may be any value appropriate for that 5 | # (repo/name:tag, image id, etc). 6 | # 7 | # When a repo has files that match the `enable_regexps`, that engine will be 8 | # enabled by default in the codeclimate.yml file. That file will also have in it 9 | # the `default_ratings_paths` globs, which are used during analysis to determine 10 | # which files should be rated. 11 | # 12 | brakeman: 13 | image: codeclimate/codeclimate-brakeman 14 | description: Static analysis tool which checks Ruby on Rails applications for security vulnerabilities. 15 | community: false 16 | upgrade_languages: 17 | - Ruby 18 | enable_regexps: 19 | - ^app\/.*\.rb 20 | default_ratings_paths: 21 | - "Gemfile.lock" 22 | - "**.erb" 23 | - "**.haml" 24 | - "**.rb" 25 | - "**.rhtml" 26 | - "**.slim" 27 | bundler-audit: 28 | image: codeclimate/codeclimate-bundler-audit 29 | description: Patch-level verification for Bundler. 30 | community: false 31 | upgrade_languages: 32 | - Ruby 33 | enable_regexps: 34 | - ^Gemfile\.lock$ 35 | default_ratings_paths: 36 | - Gemfile.lock 37 | csslint: 38 | image: codeclimate/codeclimate-csslint 39 | description: Automated linting of Cascading Stylesheets. 40 | community: false 41 | enable_regexps: 42 | - \.css$ 43 | default_ratings_paths: 44 | - "**.css" 45 | coffeelint: 46 | image: codeclimate/codeclimate-coffeelint 47 | description: A style checker for CoffeeScript. 48 | community: false 49 | enable_regexps: 50 | - \.coffee$ 51 | default_ratings_paths: 52 | - "**.coffee" 53 | duplication: 54 | image: codeclimate/codeclimate-duplication 55 | description: Structural duplication detection for Ruby, Python, JavaScript, and PHP. 56 | community: false 57 | enable_regexps: 58 | - \.inc$ 59 | - \.js$ 60 | - \.jsx$ 61 | - \.module$ 62 | - \.php$ 63 | - \.py$ 64 | - \.rb$ 65 | default_ratings_paths: 66 | - "**.inc" 67 | - "**.js" 68 | - "**.jsx" 69 | - "**.module" 70 | - "**.php" 71 | - "**.py" 72 | - "**.rb" 73 | default_config: 74 | languages: 75 | - ruby 76 | - javascript 77 | - python 78 | - php 79 | eslint: 80 | image: codeclimate/codeclimate-eslint 81 | description: A JavaScript/JSX linting utility. 82 | community: false 83 | upgrade_languages: 84 | - JavaScript 85 | enable_regexps: 86 | - \.js$ 87 | - \.jsx$ 88 | default_ratings_paths: 89 | - "**.js" 90 | - "**.jsx" 91 | gofmt: 92 | image: codeclimate/codeclimate-gofmt 93 | description: Checks the formatting of Go programs. 94 | community: true 95 | enable_regexps: 96 | - \.go$ 97 | default_ratings_paths: 98 | - "**.go" 99 | golint: 100 | image: codeclimate/codeclimate-golint 101 | description: A linter for Go. 102 | community: true 103 | enable_regexps: 104 | - \.go$ 105 | default_ratings_paths: 106 | - "**.go" 107 | govet: 108 | image: codeclimate/codeclimate-govet 109 | description: Reports suspicious constructs in Go programs. 110 | community: true 111 | enable_regexps: 112 | - \.go$ 113 | default_ratings_paths: 114 | - "**.go" 115 | fixme: 116 | image: codeclimate/codeclimate-fixme 117 | description: Finds FIXME, TODO, HACK, etc. comments. 118 | community: false 119 | enable_regexps: 120 | - .+ 121 | default_ratings_paths: [] 122 | foodcritic: 123 | image: codeclimate/codeclimate-foodcritic 124 | description: Lint tool for Chef cookbooks. 125 | community: true 126 | enable_regexps: 127 | default_ratings_paths: 128 | hlint: 129 | image: codeclimate/codeclimate-hlint 130 | description: Linter for Haskell programs. 131 | community: true 132 | enable_regexps: 133 | - \.hs$ 134 | default_ratings_paths: 135 | - "**.hs" 136 | nodesecurity: 137 | image: codeclimate/codeclimate-nodesecurity 138 | description: Security tool for Node.js dependencies. 139 | community: true 140 | enable_regexps: 141 | default_ratings_paths: 142 | pep8: 143 | image: codeclimate/codeclimate-pep8 144 | description: Static analysis tool to check Python code against the style conventions outlined in PEP-8. 145 | community: false 146 | enable_regexps: 147 | default_ratings_paths: 148 | - "**.py" 149 | phpcodesniffer: 150 | image: codeclimate/codeclimate-phpcodesniffer 151 | description: Detects violations of a defined set of coding standards in PHP. 152 | community: false 153 | enable_regexps: 154 | default_ratings_paths: 155 | - "**.php" 156 | - "**.module" 157 | - "**.inc" 158 | phpmd: 159 | image: codeclimate/codeclimate-phpmd 160 | description: A PHP static analysis tool. 161 | community: false 162 | upgrade_languages: 163 | - PHP 164 | enable_regexps: 165 | - \.php$ 166 | - \.module$ 167 | - \.inc$ 168 | default_ratings_paths: 169 | - "**.php" 170 | - "**.module" 171 | - "**.inc" 172 | radon: 173 | image: codeclimate/codeclimate-radon 174 | description: Python tool used to compute Cyclomatic Complexity. 175 | community: false 176 | upgrade_languages: 177 | - Python 178 | enable_regexps: 179 | - \.py$ 180 | default_ratings_paths: 181 | - "**.py" 182 | requiresafe: 183 | image: codeclimate/codeclimate-nodesecurity 184 | description: Security tool for Node.js dependencies. 185 | community: true 186 | enable_regexps: 187 | default_ratings_paths: 188 | rubocop: 189 | image: codeclimate/codeclimate-rubocop 190 | description: A Ruby static code analyzer, based on the community Ruby style guide. 191 | community: false 192 | upgrade_languages: 193 | - Ruby 194 | enable_regexps: 195 | - \.rb$ 196 | default_ratings_paths: 197 | - "**.rb" 198 | rubymotion: 199 | image: codeclimate/codeclimate-rubymotion 200 | description: Rubymotion-specific rubocop checks. 201 | community: true 202 | enable_regexps: 203 | default_ratings_paths: 204 | - "**.rb" 205 | scss-lint: 206 | image: codeclimate/codeclimate-scss-lint 207 | description: Configurable tool for writing clean and consistent SCSS. 208 | community: true 209 | enable_regexps: 210 | default_ratings_paths: 211 | - "**.scss" 212 | watson: 213 | image: codeclimate/codeclimate-watson 214 | description: A young Ember Doctor to help you fix your code. 215 | community: true 216 | enable_regexps: 217 | default_ratings_paths: 218 | - "app/**" 219 | -------------------------------------------------------------------------------- /lib/cc/cli/test.rb: -------------------------------------------------------------------------------- 1 | require "shellwords" 2 | require "cc/yaml" 3 | 4 | module CC 5 | module CLI 6 | class Marker 7 | def self.from_text(engine_name, test_file, line_number, text) 8 | marker = Marker.new(line_number, text) 9 | attrs = attrs_from_marker(text.sub(/^.*\[issue\] ?/, "")) 10 | 11 | marker.issue = attrs.merge( 12 | "engine_name" => engine_name, 13 | "location" => { 14 | "path" => test_file, 15 | "lines" => { 16 | "begin" => line_number + 1, 17 | "end" => line_number + 1, 18 | }, 19 | }, 20 | ) 21 | 22 | if marker.issue["category"] 23 | marker.issue["categories"] = Array.wrap(marker.issue["category"]) 24 | marker.issue.delete("category") 25 | end 26 | 27 | marker 28 | end 29 | 30 | def self.attrs_from_marker(text) 31 | if text.blank? 32 | {} 33 | else 34 | matches = text.scan(/([a-z\._-]+)=(?:(")((?:\\.|[^"])*)"|([^\s]*))/).map(&:compact) 35 | 36 | key_values = matches.map do |match| 37 | munge_match(match) 38 | end 39 | 40 | Hash[key_values] 41 | end 42 | end 43 | 44 | def self.munge_match(match) 45 | if match.size == 3 # Quoted 46 | key, _, value = match 47 | value = '"' + value + '"' 48 | else 49 | key, value = match 50 | end 51 | 52 | [key, munge_value(value)] 53 | end 54 | 55 | def self.munge_value(value) 56 | JSON.load(value) 57 | rescue JSON::ParserError 58 | value 59 | end 60 | 61 | attr_reader :line, :line_text 62 | attr_accessor :issue 63 | 64 | def initialize(line, line_text) 65 | @line = line 66 | @line_text = line_text 67 | @issue = issue 68 | end 69 | end 70 | 71 | class Test < Command 72 | def run 73 | @engine_name = @args.first 74 | 75 | if @engine_name.blank? 76 | fatal "Usage: codeclimate test #{rainbow.wrap('engine_name').underline}" 77 | end 78 | 79 | test_engine 80 | end 81 | 82 | def test_engine 83 | within_tempdir do 84 | prepare_working_dir 85 | unpack_tests 86 | run_tests 87 | end 88 | ensure 89 | remove_null_container 90 | end 91 | 92 | def within_tempdir 93 | tmpdir = create_tmpdir 94 | 95 | Dir.chdir(tmpdir) do 96 | yield 97 | end 98 | ensure 99 | FileUtils.rm_rf(tmpdir) 100 | end 101 | 102 | def unpack_tests 103 | test_paths.each do |test_path| 104 | unpack(test_path) 105 | end 106 | end 107 | 108 | def run_tests 109 | Dir["*"].each do |file| 110 | next unless File.directory?(file) 111 | process_directory(file) 112 | end 113 | end 114 | 115 | def process_directory(test_directory) 116 | markers = markers_in(test_directory) 117 | 118 | actual_issues = issues_in(test_directory) 119 | compare_issues(actual_issues, markers) 120 | end 121 | 122 | def compare_issues(actual_issues, markers) 123 | markers.each do |marker| 124 | validate_issue(marker, actual_issues) 125 | end 126 | 127 | validate_unexpected_issues(actual_issues) 128 | end 129 | 130 | def validate_issue(marker, actual_issues) 131 | if (index = locate_match(actual_issues, marker)) 132 | announce_pass(marker) 133 | actual_issues.delete_at(index) 134 | else 135 | announce_fail(marker, actual_issues) 136 | fatal "Expected issue not found." 137 | end 138 | end 139 | 140 | def locate_match(actual_issues, marker) 141 | actual_issues.each_with_index do |actual, index| 142 | if fuzzy_match(marker.issue, actual) 143 | return index 144 | end 145 | end 146 | 147 | nil 148 | end 149 | 150 | def announce_pass(marker) 151 | say format("PASS %3d: %s", marker.line, marker.line_text) 152 | end 153 | 154 | def announce_fail(marker, actual_issues) 155 | say colorize(format("FAIL %3d: %s", marker.line, marker.line_text), :red) 156 | say colorize("Expected:", :yellow) 157 | say colorize(JSON.pretty_generate(marker.issue), :yellow) 158 | say "\n" 159 | say colorize("Actual:", :yellow) 160 | say colorize(JSON.pretty_generate(actual_issues), :yellow) 161 | end 162 | 163 | def validate_unexpected_issues(actual_issues) 164 | if actual_issues.any? 165 | say colorize("Actuals not empty after matching.", :red) 166 | say "\n" 167 | say colorize("#{actual_issues.size} remaining:", :yellow) 168 | say colorize(JSON.pretty_generate(actual_issues), :yellow) 169 | fatal "Unexpected issue found." 170 | end 171 | end 172 | 173 | def fuzzy_match(expected, actual) 174 | expected.all? do |key, value| 175 | actual[key] == value 176 | end 177 | end 178 | 179 | def issues_in(test_directory) 180 | issue_docs = capture_stdout do 181 | codeclimate_analyze(test_directory) 182 | end 183 | 184 | JSON.parse(issue_docs) 185 | end 186 | 187 | def codeclimate_analyze(relative_path) 188 | codeclimate_path = File.expand_path(File.join(File.dirname(__FILE__), "../../../bin/codeclimate")) 189 | 190 | system([ 191 | "unset CODE_PATH &&", 192 | "unset FILESYSTEM_DIR &&", 193 | Shellwords.escape(codeclimate_path), 194 | "analyze", 195 | "--engine", Shellwords.escape(@engine_name), 196 | "-f", "json", 197 | Shellwords.escape(relative_path) 198 | ].join(" ")) 199 | end 200 | 201 | def prepare_working_dir 202 | `git init` 203 | 204 | File.open(".codeclimate.yml", "w") do |config| 205 | config.write("engines:\n #{@engine_name}:\n enabled: true") 206 | end 207 | end 208 | 209 | def markers_in(test_directory) 210 | [].tap do |markers| 211 | Dir[File.join(test_directory, "**/*")].each do |file| 212 | next unless File.file?(file) 213 | lines = File.readlines(file) 214 | 215 | lines.each_with_index do |line, index| 216 | if line =~ /\[issue\].*/ 217 | markers << Marker.from_text(@engine_name, file, index + 1, line) 218 | end 219 | end 220 | end 221 | end 222 | end 223 | 224 | def create_tmpdir 225 | tmpdir = File.join("/tmp/cc", SecureRandom.uuid) 226 | FileUtils.mkdir_p(tmpdir) 227 | tmpdir 228 | end 229 | 230 | def unpack(path) 231 | system("docker cp #{null_container_id}:#{path} .") 232 | end 233 | 234 | def null_container_id 235 | # docker cp only works with containers, not images so 236 | # workaround it by creating a throwaway container 237 | @null_container_id = `docker run -d #{engine_image} false`.chomp 238 | end 239 | 240 | def remove_null_container 241 | `docker rm -f #{null_container_id}` if null_container_id 242 | end 243 | 244 | def test_paths 245 | Array.wrap(engine_spec["test_paths"]) 246 | end 247 | 248 | def engine_spec 249 | @engine_spec ||= JSON.parse(`docker run --rm #{engine_image} cat /engine.json`) 250 | end 251 | 252 | def engine_image 253 | engine_registry[@engine_name]["image"] 254 | end 255 | 256 | # Stolen from ActiveSupport (where it was deprecated) 257 | def capture_stdout 258 | captured_stream = Tempfile.new("stdout") 259 | origin_stream = $stdout.dup 260 | $stdout.reopen(captured_stream) 261 | 262 | yield 263 | 264 | $stdout.rewind 265 | return captured_stream.read 266 | ensure 267 | captured_stream.close 268 | captured_stream.unlink 269 | $stdout.reopen(origin_stream) 270 | end 271 | end 272 | end 273 | end 274 | -------------------------------------------------------------------------------- /spec/cc/cli/init_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "cc/yaml" 3 | 4 | module CC::CLI 5 | describe Init do 6 | include Factory 7 | include FileSystemHelpers 8 | include ProcHelpers 9 | 10 | around do |test| 11 | within_temp_dir { test.call } 12 | end 13 | 14 | describe "#run" do 15 | describe "when no .codeclimate.yml file is present in working directory" do 16 | it "creates a correct .codeclimate.yml file and reports successful creation" do 17 | filesystem.exist?(".codeclimate.yml").must_equal(false) 18 | write_fixture_source_files 19 | 20 | stdout, _, exit_code = capture_io_and_exit_code do 21 | init = Init.new 22 | init.run 23 | end 24 | 25 | new_content = File.read(".codeclimate.yml") 26 | 27 | stdout.must_match "Config file .codeclimate.yml successfully generated." 28 | exit_code.must_equal 0 29 | filesystem.exist?(".codeclimate.yml").must_equal(true) 30 | 31 | YAML.safe_load(new_content).must_equal({ 32 | "engines" => { 33 | "csslint" => { "enabled"=>true }, 34 | "duplication" => { "enabled" => true, "config" => { "languages" => ["ruby", "javascript", "python", "php"] } }, 35 | "eslint" => { "enabled"=>true }, 36 | "fixme" => { "enabled"=>true }, 37 | "rubocop" => { "enabled"=>true }, 38 | }, 39 | "ratings" => { "paths" => ["**.css", "**.inc", "**.js", "**.jsx", "**.module", "**.php", "**.py", "**.rb"] }, 40 | "exclude_paths" => ["config/**/*", "spec/**/*", "vendor/**/*"], 41 | }) 42 | 43 | CC::Yaml.parse(new_content).errors.must_be_empty 44 | end 45 | 46 | describe 'when default config for engine is available' do 47 | describe 'when no config file for this engine exists in working directory' do 48 | it 'creates .engine.yml with default config' do 49 | File.write('foo.rb', 'class Foo; end') 50 | 51 | stdout, _, exit_code = capture_io_and_exit_code do 52 | init = Init.new 53 | init.run 54 | end 55 | 56 | new_content = File.read('.rubocop.yml') 57 | 58 | stdout.must_match 'Config file .rubocop.yml successfully generated.' 59 | exit_code.must_equal 0 60 | filesystem.exist?('.rubocop.yml').must_equal(true) 61 | YAML.safe_load(new_content).keys.must_include('AllCops') 62 | end 63 | end 64 | 65 | describe 'when config file for this engine already exists in working directory' do 66 | it 'skips engine config file generation' do 67 | File.write('foo.rb', 'class Foo; end') 68 | 69 | content_before = 'test content' 70 | File.write('.rubocop.yml', content_before) 71 | 72 | stdout, _, _ = capture_io_and_exit_code do 73 | init = Init.new 74 | init.run 75 | end 76 | 77 | content_after = File.read('.rubocop.yml') 78 | 79 | stdout.must_match 'Skipping generating .rubocop.yml file (already exists).' 80 | filesystem.exist?('.rubocop.yml').must_equal(true) 81 | content_after.must_equal(content_before) 82 | end 83 | end 84 | end 85 | end 86 | 87 | describe "when a platform .codeclimate.yml file is already present in working directory" do 88 | it "does not create a new file or overwrite the old" do 89 | filesystem.exist?(".codeclimate.yml").must_equal(false) 90 | 91 | yaml_content_before = "---\nlanguages:\n Ruby: true\n" 92 | File.write(".codeclimate.yml", yaml_content_before) 93 | 94 | filesystem.exist?(".codeclimate.yml").must_equal(true) 95 | 96 | capture_io_and_exit_code do 97 | Init.new.run 98 | end 99 | 100 | content_after = File.read(".codeclimate.yml") 101 | 102 | filesystem.exist?(".codeclimate.yml").must_equal(true) 103 | content_after.must_equal(yaml_content_before) 104 | end 105 | 106 | it "warns that there is a .codeclimate.yml file already present" do 107 | filesystem.exist?(".codeclimate.yml").must_equal(false) 108 | 109 | File.new(".codeclimate.yml", "w") 110 | 111 | filesystem.exist?(".codeclimate.yml").must_equal(true) 112 | 113 | stdout, _, exit_code = capture_io_and_exit_code do 114 | Init.new.run 115 | end 116 | 117 | stdout.must_match("WARNING: Config file .codeclimate.yml already present.") 118 | exit_code.must_equal 0 119 | end 120 | 121 | it "still generates default config files" do 122 | filesystem.exist?(".codeclimate.yml").must_equal(false) 123 | 124 | File.new(".codeclimate.yml", "w") 125 | 126 | filesystem.exist?(".codeclimate.yml").must_equal(true) 127 | 128 | init = Init.new 129 | 130 | init.expects(:create_default_configs) 131 | 132 | _, stderr, exit_code = capture_io_and_exit_code do 133 | init.run 134 | end 135 | end 136 | end 137 | 138 | describe "when --upgrade flag is on" do 139 | it "refuses to upgrade a platform config" do 140 | filesystem.exist?(".codeclimate.yml").must_equal(false) 141 | 142 | yaml_content_before = yaml_with_rubocop_enabled 143 | File.write(".codeclimate.yml", yaml_content_before) 144 | 145 | filesystem.exist?(".codeclimate.yml").must_equal(true) 146 | 147 | _, stderr, exit_code = capture_io_and_exit_code do 148 | Init.new(["--upgrade"]).run 149 | end 150 | 151 | content_after = File.read(".codeclimate.yml") 152 | 153 | filesystem.exist?(".codeclimate.yml").must_equal(true) 154 | content_after.must_equal(yaml_content_before) 155 | 156 | stderr.must_match "--upgrade should not be used on a .codeclimate.yml configured for the Platform" 157 | exit_code.must_equal 1 158 | end 159 | 160 | it "behaves normally if no .codeclimate.yml present" do 161 | filesystem.exist?(".codeclimate.yml").must_equal(false) 162 | write_fixture_source_files 163 | 164 | stdout, _, _ = capture_io_and_exit_code do 165 | Init.new(["--upgrade"]).run 166 | end 167 | 168 | stdout.must_match "Config file .codeclimate.yml successfully generated." 169 | 170 | new_content = File.read(".codeclimate.yml") 171 | YAML.safe_load(new_content).must_equal({ 172 | "engines" => { 173 | "csslint" => { "enabled"=>true }, 174 | "duplication" => { "enabled" => true, "config" => { "languages" => ["ruby", "javascript", "python", "php"] } }, 175 | "eslint" => { "enabled"=>true }, 176 | "fixme" => { "enabled"=>true }, 177 | "rubocop" => { "enabled"=>true }, 178 | }, 179 | "ratings" => { "paths" => ["**.css", "**.inc", "**.js", "**.jsx", "**.module", "**.php", "**.py", "**.rb"] }, 180 | "exclude_paths" => ["config/**/*", "spec/**/*", "vendor/**/*"], 181 | }) 182 | 183 | CC::Yaml.parse(new_content).errors.must_be_empty 184 | end 185 | 186 | it "upgrades if classic config is present" do 187 | filesystem.exist?(".codeclimate.yml").must_equal(false) 188 | 189 | File.write(".codeclimate.yml", create_classic_yaml) 190 | 191 | filesystem.exist?(".codeclimate.yml").must_equal(true) 192 | 193 | write_fixture_source_files 194 | 195 | stdout, _, _ = capture_io_and_exit_code do 196 | Init.new(["--upgrade"]).run 197 | end 198 | 199 | stdout.must_match "Config file .codeclimate.yml successfully upgraded." 200 | 201 | new_content = File.read(".codeclimate.yml") 202 | YAML.safe_load(new_content).must_equal({ 203 | "engines" => { 204 | "csslint" => { "enabled"=>true }, 205 | "duplication" => { "enabled" => true, "config" => { "languages" => ["ruby", "javascript", "python", "php"] } }, 206 | "fixme" => { "enabled"=>true }, 207 | "rubocop" => { "enabled"=>true }, 208 | }, 209 | "ratings" => { "paths" => ["**.css", "**.inc", "**.js", "**.jsx", "**.module", "**.php", "**.py", "**.rb"] }, 210 | "exclude_paths" => ["excluded.rb"], 211 | }) 212 | 213 | CC::Yaml.parse(new_content).errors.must_be_empty 214 | end 215 | 216 | it "fails & emits errors if existing yaml has errors" do 217 | filesystem.exist?(".codeclimate.yml").must_equal(false) 218 | 219 | File.write(".codeclimate.yml", %{ 220 | languages: 221 | Ruby: 222 | bar: foo 223 | 224 | exclude_paths: 225 | - excluded.rb 226 | }) 227 | 228 | _, stderr, exit_code = capture_io_and_exit_code do 229 | Init.new(["--upgrade"]).run 230 | end 231 | 232 | stderr.must_match "ERROR: invalid \"languages\" section" 233 | stderr.must_match "Cannot generate .codeclimate.yml" 234 | exit_code.must_equal 1 235 | end 236 | end 237 | end 238 | 239 | def filesystem 240 | @filesystem ||= make_filesystem 241 | end 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /spec/cc/analyzer/include_paths_builder_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module CC::Analyzer 4 | describe IncludePathsBuilder do 5 | include FileSystemHelpers 6 | 7 | around do |test| 8 | within_temp_dir { test.call } 9 | end 10 | 11 | let(:builder) { CC::Analyzer::IncludePathsBuilder.new(cc_excludes, cc_includes) } 12 | let(:cc_excludes) { [] } 13 | let(:cc_includes) { [] } 14 | let(:result) { builder.build } 15 | 16 | before do 17 | system("git init > /dev/null") 18 | FileUtils.stubs(:readable_by_all?).at_least_once.returns(true) 19 | end 20 | 21 | describe "when the source directory contains only files that are tracked or trackable in Git" do 22 | before do 23 | make_file("root_file.rb") 24 | make_file("subdir/subdir_file.rb") 25 | end 26 | 27 | it "returns a single entry for the root" do 28 | result.sort.must_equal(["./"]) 29 | end 30 | end 31 | 32 | describe "when the source directory contains a file that is not tracked or trackable in Git" do 33 | before do 34 | make_file("untrackable.rb") 35 | make_file(".gitignore", "untrackable.rb\n") 36 | make_file("trackable.rb") 37 | make_file("subdir/subdir_trackable.rb") 38 | end 39 | 40 | it "excludes that file from include_paths" do 41 | result.include?("untrackable.rb").must_equal(false) 42 | end 43 | 44 | it "keeps sibling files and directories in include_paths" do 45 | %w[subdir/ trackable.rb].each do |trackable_file_or_directory| 46 | result.include?(trackable_file_or_directory).must_equal(true) 47 | end 48 | end 49 | end 50 | 51 | describe "when the source directory contains a directory that is ignored in Git" do 52 | before do 53 | make_file("ignored/secret.rb") 54 | make_file(".gitignore", "ignored\n") 55 | make_file("trackable.rb") 56 | make_file("subdir/subdir_trackable.rb") 57 | end 58 | 59 | it "excludes that directory from include_paths" do 60 | result.include?("ignored/secret.rb").must_equal(false) 61 | result.include?("ignored").must_equal(false) 62 | end 63 | 64 | it "keeps sibling files and directories in include_paths" do 65 | %w[subdir/ trackable.rb].each do |trackable_file_or_directory| 66 | result.include?(trackable_file_or_directory).must_equal(true) 67 | end 68 | end 69 | end 70 | 71 | describe "when the source directory contains a file that is excluded by exclude_paths" do 72 | let(:cc_excludes) { ["untrackable.rb"] } 73 | 74 | before do 75 | make_file("untrackable.rb") 76 | make_file("trackable.rb") 77 | make_file("subdir/subdir_trackable.rb") 78 | end 79 | 80 | it "excludes that file from include_paths" do 81 | result.include?("untrackable.rb").must_equal(false) 82 | end 83 | 84 | it "keeps sibling files and directories in include_paths" do 85 | %w[subdir/ trackable.rb].each do |trackable_file_or_directory| 86 | result.include?(trackable_file_or_directory).must_equal(true) 87 | end 88 | end 89 | end 90 | 91 | describe "when there is a subdirectory that isn't readable by all but is excluded in .codeclimate.yml" do 92 | let(:cc_excludes) { ["unreadable_subdir/*"] } 93 | 94 | before do 95 | make_file("unreadable_subdir/secret.rb") 96 | FileUtils.expects(:readable_by_all?).with("unreadable_subdir").at_least_once.returns(false) 97 | make_file("trackable.rb") 98 | make_file("subdir/subdir_trackable.rb") 99 | end 100 | 101 | it "excludes that directory from include_paths" do 102 | result.include?("unreadable_subdir/").must_equal(false) 103 | result.include?("unreadable_subdir/secret.rb").must_equal(false) 104 | end 105 | 106 | it "keeps sibling files and directories in include_paths" do 107 | %w[subdir/ trackable.rb].each do |trackable_file_or_directory| 108 | result.include?(trackable_file_or_directory).must_equal(true) 109 | end 110 | end 111 | end 112 | 113 | describe "when there is a subdirectory that isn't readable by all but is excluded in .gitignore" do 114 | before do 115 | make_file("unreadable_subdir/secret.rb") 116 | FileUtils.stubs(:readable_by_all?).with("unreadable_subdir").at_least_once.returns(false) 117 | make_file(".gitignore", "unreadable_subdir\n") 118 | make_file("trackable.rb") 119 | make_file("subdir/subdir_trackable.rb") 120 | end 121 | 122 | it "excludes that directory from include_paths" do 123 | result.include?("unreadable_subdir/").must_equal(false) 124 | result.include?("unreadable_subdir/secret.rb").must_equal(false) 125 | end 126 | 127 | it "keeps sibling files and directories in include_paths" do 128 | %w[subdir/ trackable.rb].each do |trackable_file_or_directory| 129 | result.include?(trackable_file_or_directory).must_equal(true) 130 | end 131 | end 132 | end 133 | 134 | describe "when there is a subdirectory that isn't readable by all and isn't excluded in either .codeclimate.yml or .gitignore" do 135 | before do 136 | make_file("unreadable_subdir/secret.rb") 137 | FileUtils.expects(:readable_by_all?).with("unreadable_subdir").at_least_once.returns(false) 138 | make_file("trackable.rb") 139 | make_file("subdir/subdir_trackable.rb") 140 | end 141 | 142 | it "excludes that directory from include_paths" do 143 | result.include?("./").must_equal(false) 144 | result.include?("unreadable_subdir/").must_equal(false) 145 | result.include?("unreadable_subdir/secret.rb").must_equal(false) 146 | end 147 | 148 | it "keeps sibling files and directories in include_paths" do 149 | %w[subdir/ trackable.rb].each do |trackable_file_or_directory| 150 | result.include?(trackable_file_or_directory).must_equal(true) 151 | end 152 | end 153 | end 154 | 155 | describe "when there is a file that isn't readable by all but is excluded by .codeclimate.yml" do 156 | let(:cc_excludes) { ["unreadable.rb"] } 157 | 158 | before do 159 | make_file("unreadable.rb") 160 | make_file("trackable.rb") 161 | make_file("subdir/subdir_trackable.rb") 162 | end 163 | 164 | it "excludes that file from include_paths" do 165 | result.include?("./").must_equal(false) 166 | result.include?("unreadable.rb").must_equal(false) 167 | end 168 | 169 | it "keeps sibling files and directories in include_paths" do 170 | %w[subdir/ trackable.rb].each do |trackable_file_or_directory| 171 | result.include?(trackable_file_or_directory).must_equal(true) 172 | end 173 | end 174 | end 175 | 176 | describe "when there is a file that isn't readable by all but is excluded by .gitignore" do 177 | before do 178 | make_file("unreadable.rb") 179 | make_file(".gitignore", "unreadable.rb\n") 180 | make_file("trackable.rb") 181 | make_file("subdir/subdir_trackable.rb") 182 | end 183 | 184 | it "excludes that file from include_paths" do 185 | result.include?("./").must_equal(false) 186 | result.include?("unreadable.rb").must_equal(false) 187 | end 188 | 189 | it "keeps sibling files and directories in include_paths" do 190 | %w[subdir/ trackable.rb].each do |trackable_file_or_directory| 191 | result.include?(trackable_file_or_directory).must_equal(true) 192 | end 193 | end 194 | end 195 | 196 | describe "when there is a file that isn't readable by all and is not excluded by either .codeclimate.yml or .gitignore" do 197 | before do 198 | make_file("subdir/unreadable.rb") 199 | FileUtils.readable_by_all?(".") 200 | FileUtils.stubs(:readable_by_all?).with("subdir/unreadable.rb").at_least_once.returns(false) 201 | make_file("trackable.rb") 202 | make_file("subdir/subdir_trackable.rb") 203 | end 204 | 205 | it "generates correct paths" do 206 | builder.build.sort.must_equal ["subdir/subdir_trackable.rb", "trackable.rb"].sort 207 | end 208 | end 209 | 210 | describe "when a symlink points to an ancestor directory" do 211 | before do 212 | FileUtils.mkdir("subdir") 213 | FileUtils.ln_s("../subdir", "subdir/subdir") 214 | end 215 | 216 | it "doesn't follow the symlink" do 217 | result.include?("./").must_equal(true) 218 | result.include?("subdir/subdir/").must_equal(false) 219 | end 220 | end 221 | 222 | describe "when cc_include_paths are passed in addition to excludes" do 223 | let(:cc_excludes) { ["untrackable.rb"] } 224 | let(:cc_includes) { ["untrackable.rb", "subdir"] } 225 | 226 | before do 227 | make_file("untrackable.rb") 228 | make_file("trackable.rb") 229 | make_file("subdir/subdir_trackable.rb") 230 | make_file("subdir/foo.rb") 231 | make_file("subdir/baz.rb") 232 | end 233 | 234 | it "includes requested paths" do 235 | result.include?("subdir/").must_equal(true) 236 | end 237 | 238 | it "omits requested paths that are excluded by .codeclimate.yml" do 239 | result.include?("untrackable.rb").must_equal(false) 240 | end 241 | end 242 | 243 | describe "when .gitignore is a directory" do 244 | before do 245 | FileUtils.mkdir(".gitignore") 246 | end 247 | 248 | it "skips it entirely" do 249 | FileUtils.readable_by_all?(".") 250 | result.include?("./").must_equal(true) 251 | end 252 | end 253 | 254 | describe "when analyzing a single file" do 255 | let(:cc_includes) { ["subdir/subdir_file.rb"] } 256 | 257 | it "returns the file" do 258 | make_file("root_file.rb") 259 | make_file("subdir/subdir_file.rb") 260 | 261 | builder.build.must_equal(["subdir/subdir_file.rb"]) 262 | end 263 | end 264 | end 265 | end 266 | --------------------------------------------------------------------------------