foodcritic 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ require 'foodcritic'
3
+ unless ARGV.length == 1 and Dir.exists?(ARGV[0])
4
+ STDERR.puts 'foodcritic [cookbook_path]'
5
+ exit 1
6
+ end
7
+ review = FoodCritic::Linter.new.check(ARGV[0])
8
+ puts review unless review.warnings.empty?
@@ -0,0 +1,5 @@
1
+ require 'foodcritic/domain'
2
+ require 'foodcritic/helpers'
3
+ require 'foodcritic/dsl'
4
+ require 'foodcritic/linter'
5
+ require 'foodcritic/version'
@@ -0,0 +1,52 @@
1
+ module FoodCritic
2
+
3
+ # A warning of a possible issue
4
+ class Warning
5
+ attr_reader :rule, :match
6
+
7
+ # Create a new warning
8
+ #
9
+ # @param [FoodCritic::Rule] rule The rule which raised this warning
10
+ # @param [Hash] match The match data
11
+ # @option match [String] :filename The filename the warning was raised against
12
+ # @option match [Integer] :line The identified line
13
+ # @option match [Integer] :column The identified column
14
+ def initialize(rule, match={})
15
+ @rule, @match = rule, match
16
+ end
17
+ end
18
+
19
+ # The collected warnings (if any) raised against a cookbook tree.
20
+ class Review
21
+
22
+ attr_reader :warnings
23
+
24
+ # Create a new review
25
+ #
26
+ # @param [Array] warnings The warnings raised in this review
27
+ def initialize(warnings)
28
+ @warnings = warnings
29
+ end
30
+
31
+ # Returns a string representation of this review.
32
+ #
33
+ # @return [String] Review as a string, this representation is liable to change.
34
+ def to_s
35
+ @warnings.map { |w| "#{w.rule.code}: #{w.rule.name}: #{w.match[:filename]}:#{w.match[:line]}" }.sort.uniq.join("\n")
36
+ end
37
+ end
38
+
39
+ # A rule to be matched against.
40
+ class Rule
41
+ attr_accessor :code, :name, :description, :recipe
42
+
43
+ # Create a new rule
44
+ #
45
+ # @param [String] code The short unique identifier for this rule, e.g. 'FC001'
46
+ # @param [String] name The short descriptive name of this rule presented to the end user.
47
+ def initialize(code, name)
48
+ @code, @name = code, name
49
+ end
50
+ end
51
+
52
+ end
@@ -0,0 +1,44 @@
1
+ module FoodCritic
2
+
3
+ # The DSL methods exposed for defining rules.
4
+ class RuleDsl
5
+ attr_reader :rules
6
+ include Helpers
7
+
8
+ # Define a new rule
9
+ #
10
+ # @param [String] code The short unique identifier for this rule, e.g. 'FC001'
11
+ # @param [String] name The short descriptive name of this rule presented to the end user.
12
+ # @param [Block] block The rule definition
13
+ def rule(code, name, &block)
14
+ @rules = [] if @rules.nil?
15
+ @rules << Rule.new(code, name)
16
+ yield self
17
+ end
18
+
19
+ # Set the rule description
20
+ #
21
+ # @param [String] description Set the rule description.
22
+ def description(description)
23
+ rules.last.description = description
24
+ end
25
+
26
+ # Define a matcher that will be passed the AST with this method.
27
+ #
28
+ # @param [block] block Your implemented matcher that returns a match Hash.
29
+ def recipe(&block)
30
+ rules.last.recipe = block
31
+ end
32
+
33
+ # Load the ruleset
34
+ #
35
+ # @param [String] filename The path to the ruleset to load
36
+ # @return [Array] The loaded rules, ready to be matched against provided cookbooks.
37
+ def self.load(filename)
38
+ dsl = RuleDsl.new
39
+ dsl.instance_eval(File.read(filename), filename)
40
+ dsl.rules
41
+ end
42
+ end
43
+
44
+ end
@@ -0,0 +1,106 @@
1
+ module FoodCritic
2
+
3
+ # Helper methods that form part of the Rules DSL.
4
+ module Helpers
5
+
6
+ # Given an AST type and parsed tree, return the matching subset.
7
+ #
8
+ # @param [Symbol] type The type of AST node to look for
9
+ # @param [Array] node The parsed AST (or part there-of)
10
+ # @return [Array] Matching nodes
11
+ def ast(type, node)
12
+ result = []
13
+ result = [node] if node.first == type
14
+ node.each { |n| result += ast(type, n) if n.respond_to?(:each) }
15
+ result
16
+ end
17
+
18
+ # Does the specified recipe check for Chef Solo?
19
+ #
20
+ # @param [Array] ast The AST of the cookbook recipe to check.
21
+ # @return [Boolean] True if there is a test for Chef::Config[:solo] in the recipe
22
+ def checks_for_chef_solo?(ast)
23
+ arefs = ast(:aref, ast)
24
+ arefs.any? do |aref|
25
+ ast(:@const, aref).map { |const| const[1] } == ['Chef', 'Config'] and
26
+ ast(:@ident, ast(:symbol, aref)).map { |sym| sym.drop(1).first }.include? 'solo'
27
+ end
28
+ end
29
+
30
+ # Find Chef resources of the specified type.
31
+ # TODO: Include blockless resources
32
+ #
33
+ # @param [Array] ast The AST of the cookbook recipe to check
34
+ # @param [String] type The type of resource to look for (or nil for all resources)
35
+ def find_resources(ast, type = nil)
36
+ ast(:method_add_block, ast).find_all do |resource|
37
+ resource[1][0] == :command and resource[1][1][0] == :@ident and (type.nil? || resource[1][1][1] == type)
38
+ end
39
+ end
40
+
41
+ # Return the type, e.g. 'package' for a given resource
42
+ #
43
+ # @param [Array] resource The resource AST
44
+ # @return [String] The type of resource
45
+ def resource_type(resource)
46
+ resource[1][1][1]
47
+ end
48
+
49
+ # Retrieve the name attribute associated with the specified resource.
50
+ #
51
+ # @param [Array] resource The resource AST to lookup the name attribute under
52
+ def resource_name(resource)
53
+ ast(:@tstring_content, resource[1]).flatten[1]
54
+ end
55
+
56
+ # Retrieve a single-valued attribute from the specified resource.
57
+ #
58
+ # @param [String] name The attribute name
59
+ # @param [Array] resource The resource AST to lookup the attribute under
60
+ # @return [String] The attribute value for the specified attribute
61
+ def resource_attribute(name, resource)
62
+ cmd = ast(:command, ast(:do_block, resource))
63
+ atts = cmd.find_all { |att| ast(:@ident, att).flatten.drop(1).first == name }
64
+ value = ast(:@tstring_content, atts).flatten.drop(1)
65
+ unless value.empty?
66
+ return value.first
67
+ end
68
+ nil
69
+ end
70
+
71
+ # Retrieve all attributes from the specified resource.
72
+ #
73
+ # @param [Array] resource The resource AST
74
+ # @return [Hash] The resource attributes
75
+ def resource_attributes(resource)
76
+ atts = {:name => resource_name(resource)}
77
+ ast(:command, ast(:do_block, resource)).find_all{|cmd| cmd.first == :command}.each do |cmd|
78
+ atts[cmd[1][1]] = ast(:@tstring_content, cmd[2]).flatten[1] || ast(:@ident, cmd[2]).flatten[1]
79
+ end
80
+ atts
81
+ end
82
+
83
+ # Retrieve all resources of a given type
84
+ #
85
+ # @param [Array] ast The recipe AST
86
+ # @return [Array] The matching resources
87
+ def resources_by_type(ast)
88
+ result = Hash.new{|hash, key| hash[key] = Array.new}
89
+ find_resources(ast).each{|resource| result[resource_type(resource)] << resource}
90
+ result
91
+ end
92
+
93
+ # Retrieve the attributes as a hash for all resources of a given type.
94
+ #
95
+ # @param [Array] ast The recipe AST
96
+ # @return [Hash] An array of resource attributes keyed by type.
97
+ def resource_attributes_by_type(ast)
98
+ result = {}
99
+ resources_by_type(ast).each do |type,resources|
100
+ result[type] = resources.map{|resource| resource_attributes(resource)}
101
+ end
102
+ result
103
+ end
104
+ end
105
+
106
+ end
@@ -0,0 +1,47 @@
1
+ require 'ripper'
2
+
3
+ module FoodCritic
4
+
5
+ # The main entry point for linting your Chef cookbooks.
6
+ class Linter
7
+
8
+ # Create a new Linter, loading any defined rules.
9
+ def initialize
10
+ load_rules
11
+ end
12
+
13
+ # Review the cookbooks at the provided path, identifying potential improvements.
14
+ #
15
+ # @param [String] cookbook_path The file path to an individual cookbook directory
16
+ # @return [FoodCritic::Review] A review of your cookbooks, with any warnings issued.
17
+ def check(cookbook_path)
18
+ warnings = []
19
+ files_to_process(cookbook_path).each do |file|
20
+ ast = Ripper::SexpBuilder.new(IO.read(file)).parse
21
+ @rules.each do |rule|
22
+ rule.recipe.yield(ast).each do |match|
23
+ warnings << Warning.new(rule, match.merge({:filename => file}))
24
+ end
25
+ end
26
+ end
27
+ Review.new(warnings)
28
+ end
29
+
30
+ private
31
+
32
+ # Load the rules from the (fairly unnecessary) DSL.
33
+ def load_rules
34
+ @rules = RuleDsl.load(File.join(File.dirname(__FILE__), 'rules.rb'))
35
+ end
36
+
37
+ # Return the files within a cookbook tree that we are interested in trying to match rules against.
38
+ #
39
+ # @param [String] dir The cookbook directory
40
+ # @return [Array] The files underneath the provided directory to be processed.
41
+ def files_to_process(dir)
42
+ return [dir] unless File.directory? dir
43
+ Dir.glob(File.join(dir, '{attributes,recipes}/*.rb')) + Dir.glob(File.join(dir, '*/{attributes,recipes}/*.rb'))
44
+ end
45
+
46
+ end
47
+ end
@@ -0,0 +1,82 @@
1
+ rule "FC001", "Use symbols in preference to strings to access node attributes" do
2
+ description "When accessing node attributes you should use a symbol for a key rather than a string literal."
3
+ recipe do |ast|
4
+ matches = []
5
+ attribute_refs = %w{node default override set normal}
6
+ aref_fields = self.ast(:aref, ast) + self.ast(:aref_field, ast)
7
+ aref_fields.each do |field|
8
+ is_node_aref = attribute_refs.include? self.ast(:@ident, field).flatten.drop(1).first
9
+ if is_node_aref
10
+ literal_strings = self.ast(:@tstring_content, field)
11
+ literal_strings.each do |str|
12
+ matches << {:matched => str[1], :line => str[2].first, :column => str[2].last}
13
+ end
14
+ end
15
+ end
16
+ matches
17
+ end
18
+ end
19
+
20
+ rule "FC002", "Avoid string interpolation where not required" do
21
+ description "When setting a resource value avoid string interpolation where not required."
22
+ recipe do |ast|
23
+ matches = []
24
+ self.ast(:string_literal, ast).each do |literal|
25
+ embed_expr = self.ast(:string_embexpr, literal)
26
+ if embed_expr.size == 1
27
+ literal[1].reject! { |expr| expr == embed_expr.first }
28
+ if self.ast(:@tstring_content, literal).empty?
29
+ self.ast(:@ident, embed_expr).map { |ident| ident.flatten.drop(1) }.each do |ident|
30
+ matches << {:matched => ident[0], :line => ident[1], :column => ident[2]}
31
+ end
32
+ end
33
+ end
34
+ end
35
+ matches
36
+ end
37
+ end
38
+
39
+ rule "FC003", "Check whether you are running with chef server before using server-specific features" do
40
+ description "Ideally your cookbooks should be usable without requiring chef server."
41
+ recipe do |ast|
42
+ matches = []
43
+ function_calls = self.ast(:@ident, self.ast(:fcall, ast)).map { |fcall| fcall.drop(1).flatten }
44
+ searches = function_calls.find_all { |fcall| fcall.first == 'search' }
45
+ unless searches.empty? || checks_for_chef_solo?(ast)
46
+ searches.each { |s| matches << {:matched => s[0], :line => s[1], :column => s[2]} }
47
+ end
48
+ matches
49
+ end
50
+ end
51
+
52
+ rule "FC004", "Use a service resource to start and stop services" do
53
+ description "Avoid use of execute to control services - use the service resource instead."
54
+ recipe do |ast|
55
+ matches = []
56
+ find_resources(ast, 'execute').find_all do |cmd|
57
+ cmd_str = resource_attribute('command', cmd)
58
+ cmd_str = resource_name(cmd) if cmd_str.nil?
59
+ cmd_str.include?('/etc/init.d') || cmd_str.start_with?('service ') || cmd_str.start_with?('/sbin/service ')
60
+ end.each do |service_cmd|
61
+ exec = ast(:@ident, service_cmd).first.drop(1).flatten
62
+ matches << {:matched => exec[0], :line => exec[1], :column => exec[2]}
63
+ end
64
+ matches
65
+ end
66
+ end
67
+
68
+ rule "FC005", "Avoid repetition of resource declarations" do
69
+ description "Where you have a lot of resources that vary in only a single attribute wrap them in a loop for brevity."
70
+ recipe do |ast|
71
+ matches = []
72
+ # do all of the attributes for all resources of a given type match apart aside from one?
73
+ resource_attributes_by_type(ast).each do |type, resource_atts|
74
+ sorted_atts = resource_atts.map{|atts| atts.to_a.sort{|x,y| x.first.to_s <=> y.first.to_s }}
75
+ if sorted_atts.all?{|att| (att - sorted_atts.inject{|atts,a| atts & a}).length == 1}
76
+ first_resource = ast(:@ident, find_resources(ast, type).first).first[2]
77
+ matches << {:matched => type, :line => first_resource[0], :column => first_resource[1]}
78
+ end
79
+ end
80
+ matches
81
+ end
82
+ end
@@ -0,0 +1,3 @@
1
+ module FoodCritic
2
+ VERSION = '0.1.0'
3
+ end
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: foodcritic
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Andrew Crump
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2011-11-30 00:00:00.000000000 Z
13
+ dependencies: []
14
+ description: Lint tool for Opscode Chef cookbooks.
15
+ email:
16
+ executables:
17
+ - foodcritic
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - lib/foodcritic/domain.rb
22
+ - lib/foodcritic/dsl.rb
23
+ - lib/foodcritic/helpers.rb
24
+ - lib/foodcritic/linter.rb
25
+ - lib/foodcritic/rules.rb
26
+ - lib/foodcritic/version.rb
27
+ - lib/foodcritic.rb
28
+ - bin/foodcritic
29
+ homepage: http://acrmp.github.com/foodcritic
30
+ licenses:
31
+ - MIT
32
+ post_install_message:
33
+ rdoc_options: []
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ none: false
38
+ requirements:
39
+ - - ! '>='
40
+ - !ruby/object:Gem::Version
41
+ version: 1.9.3
42
+ required_rubygems_version: !ruby/object:Gem::Requirement
43
+ none: false
44
+ requirements:
45
+ - - ! '>='
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ segments:
49
+ - 0
50
+ hash: -3064369866529469522
51
+ requirements: []
52
+ rubyforge_project:
53
+ rubygems_version: 1.8.10
54
+ signing_key:
55
+ specification_version: 3
56
+ summary: foodcritic-0.1.0
57
+ test_files: []
58
+ has_rdoc: