foodcritic 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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: