foodcritic 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/bin/foodcritic +8 -0
- data/lib/foodcritic.rb +5 -0
- data/lib/foodcritic/domain.rb +52 -0
- data/lib/foodcritic/dsl.rb +44 -0
- data/lib/foodcritic/helpers.rb +106 -0
- data/lib/foodcritic/linter.rb +47 -0
- data/lib/foodcritic/rules.rb +82 -0
- data/lib/foodcritic/version.rb +3 -0
- metadata +58 -0
data/bin/foodcritic
ADDED
data/lib/foodcritic.rb
ADDED
@@ -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
|
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:
|