foodcritic 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,89 +1,91 @@
1
+ require 'nokogiri'
2
+ require 'xmlsimple'
3
+
1
4
  module FoodCritic
2
5
 
3
6
  # Helper methods that form part of the Rules DSL.
4
7
  module Helpers
5
8
 
6
- # Given an AST type and parsed tree, return the matching subset.
9
+ # Create a match from the specified node.
7
10
  #
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
11
+ # @param [Nokogiri::XML::Node] node The node to create a match for
12
+ # @return [Hash] Hash with the matched node name and position with the recipe
13
+ def match(node)
14
+ pos = node.xpath('descendant::pos').first
15
+ {:matched => node.name, :line => pos['line'], :column => pos['column']}
16
16
  end
17
17
 
18
18
  # Does the specified recipe check for Chef Solo?
19
19
  #
20
- # @param [Array] ast The AST of the cookbook recipe to check.
20
+ # @param [Nokogiri::XML::Node] ast The AST of the cookbook recipe to check.
21
21
  # @return [Boolean] True if there is a test for Chef::Config[:solo] in the recipe
22
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
23
+ ! ast.xpath(%q{//if/aref[count(descendant::const[@value = 'Chef' or @value = 'Config']) = 2 and
24
+ count(descendant::ident[@value='solo']) > 0]}).empty?
25
+ end
26
+
27
+ # Searches performed by the specified recipe.
28
+ #
29
+ # @param [Nokogiri::XML::Node] ast The AST of the cookbook recipe to check.
30
+ # @return [Boolean] True if the recipe performs a search
31
+ def searches(ast)
32
+ ast.xpath("//fcall/ident[@value = 'search']")
28
33
  end
29
34
 
30
35
  # Find Chef resources of the specified type.
31
36
  # TODO: Include blockless resources
32
37
  #
33
- # @param [Array] ast The AST of the cookbook recipe to check
38
+ # @param [Nokogiri::XML::Node] ast The AST of the cookbook recipe to check
34
39
  # @param [String] type The type of resource to look for (or nil for all resources)
35
40
  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
41
+ ast.xpath(%Q{//method_add_block[command/ident#{type.nil? ? '' : "[@value='#{type}']"}]})
39
42
  end
40
43
 
41
44
  # Return the type, e.g. 'package' for a given resource
42
45
  #
43
- # @param [Array] resource The resource AST
46
+ # @param [Nokogiri::XML::Node] resource The resource AST
44
47
  # @return [String] The type of resource
45
48
  def resource_type(resource)
46
- resource[1][1][1]
49
+ resource.xpath('string(command/ident/@value)')
47
50
  end
48
51
 
49
52
  # Retrieve the name attribute associated with the specified resource.
50
53
  #
51
- # @param [Array] resource The resource AST to lookup the name attribute under
54
+ # @param [Nokogiri::XML::Node] resource The resource AST to lookup the name attribute under
52
55
  def resource_name(resource)
53
- ast(:@tstring_content, resource[1]).flatten[1]
56
+ resource.xpath('string(command//tstring_content/@value)')
54
57
  end
55
58
 
56
59
  # Retrieve a single-valued attribute from the specified resource.
57
60
  #
58
61
  # @param [String] name The attribute name
59
- # @param [Array] resource The resource AST to lookup the attribute under
62
+ # @param [Nokogiri::XML::Node] resource The resource AST to lookup the attribute under
60
63
  # @return [String] The attribute value for the specified attribute
61
64
  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
65
+ resource_attributes(resource)[name]
69
66
  end
70
67
 
71
68
  # Retrieve all attributes from the specified resource.
72
69
  #
73
- # @param [Array] resource The resource AST
70
+ # @param [Nokogiri::XML::Node] resource The resource AST
74
71
  # @return [Hash] The resource attributes
75
72
  def resource_attributes(resource)
76
73
  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]
74
+ resource.xpath('do_block/descendant::command').each do |att|
75
+ if att.xpath('descendant::symbol').empty?
76
+ att_value = att.xpath('string(descendant::tstring_content/@value)')
77
+ else
78
+ att_value = att.xpath('string(descendant::symbol/ident/@value)').to_sym
79
+ end
80
+ atts[att.xpath('string(ident/@value)')] = att_value
79
81
  end
80
82
  atts
81
83
  end
82
84
 
83
85
  # Retrieve all resources of a given type
84
86
  #
85
- # @param [Array] ast The recipe AST
86
- # @return [Array] The matching resources
87
+ # @param [Nokogiri::XML::Node] ast The recipe AST
88
+ # @return [Hash] The matching resources
87
89
  def resources_by_type(ast)
88
90
  result = Hash.new{|hash, key| hash[key] = Array.new}
89
91
  find_resources(ast).each{|resource| result[resource_type(resource)] << resource}
@@ -92,7 +94,7 @@ module FoodCritic
92
94
 
93
95
  # Retrieve the attributes as a hash for all resources of a given type.
94
96
  #
95
- # @param [Array] ast The recipe AST
97
+ # @param [Nokogiri::XML::Node] ast The recipe AST
96
98
  # @return [Hash] An array of resource attributes keyed by type.
97
99
  def resource_attributes_by_type(ast)
98
100
  result = {}
@@ -101,6 +103,77 @@ module FoodCritic
101
103
  end
102
104
  result
103
105
  end
106
+
107
+ # Retrieve the recipes that are included within the given recipe AST.
108
+ #
109
+ # @param [Nokogiri::XML::Node] ast The recipe AST
110
+ # @return [Hash] include_recipe nodes keyed by included recipe name
111
+ def included_recipes(ast)
112
+ # we only support literal strings, ignoring sub-expressions
113
+ included = ast.xpath(%q{//command[ident/@value = 'include_recipe' and count(descendant::string_embexpr) = 0]/
114
+ descendant::tstring_content})
115
+ Hash[included.map{|recipe|recipe['value']}.zip(included)]
116
+ end
117
+
118
+ # The name of the cookbook containing the specified file.
119
+ #
120
+ # @param [String] file The file in the cookbook
121
+ # @return [String] The name of the containing cookbook
122
+ def cookbook_name(file)
123
+ File.basename(File.absolute_path(File.join(File.dirname(file), '..')))
124
+ end
125
+
126
+ # The dependencies declared in cookbook metadata.
127
+ #
128
+ # @param [Nokogiri::XML::Node] ast The metadata rb AST
129
+ # @return [Array] List of cookbooks depended on
130
+ def declared_dependencies(ast)
131
+ deps = ast.xpath("//command[ident/@value='depends']/descendant::args_add/descendant::tstring_content")
132
+ # handle quoted word arrays
133
+ var_ref = ast.xpath("//command[ident/@value='depends']/descendant::var_ref/ident")
134
+ deps += ast.xpath(%Q{//block_var/params/ident#{var_ref.first['value']}/ancestor::method_add_block/
135
+ call/descendant::tstring_content}) unless var_ref.empty?
136
+ deps.map{|dep| dep['value']}
137
+ end
138
+
139
+ # If the provided node is the line / column information.
140
+ #
141
+ # @param [Nokogiri::XML::Node] node A node within the AST
142
+ # @return [Boolean] True if this node holds the position data
143
+ def position_node?(node)
144
+ node.respond_to?(:length) and node.length == 2 and node.respond_to?(:all?) and node.all?{|child| child.respond_to?(:to_i)}
145
+ end
146
+
147
+ # Recurse the nested arrays provided by Ripper to create an intermediate Hash for ease of searching.
148
+ #
149
+ # @param [Nokogiri::XML::Node] node The AST
150
+ # @return [Hash] The friendlier Hash.
151
+ def ast_to_hash(node)
152
+ result = {}
153
+ if node.respond_to?(:each)
154
+ node.drop(1).each do |child|
155
+ if position_node?(child)
156
+ result[:pos] = {:line => child.first, :column => child[1]}
157
+ else
158
+ if child.respond_to?(:first)
159
+ result[child.first.to_s.gsub(/[^a-z_]/, '')] = ast_to_hash(child)
160
+ else
161
+ result[:value] = child unless child.nil?
162
+ end
163
+ end
164
+ end
165
+ end
166
+ result
167
+ end
168
+
169
+ # Read the AST for the given Ruby file
170
+ #
171
+ # @param [String] file The file to read
172
+ # @return [Nokogiri::XML::Node] The recipe AST
173
+ def read_file(file)
174
+ Nokogiri::XML(XmlSimple.xml_out(ast_to_hash(Ripper::SexpBuilder.new(IO.read(file)).parse)))
175
+ end
176
+
104
177
  end
105
178
 
106
179
  end
@@ -5,6 +5,8 @@ module FoodCritic
5
5
  # The main entry point for linting your Chef cookbooks.
6
6
  class Linter
7
7
 
8
+ include FoodCritic::Helpers
9
+
8
10
  # Create a new Linter, loading any defined rules.
9
11
  def initialize
10
12
  load_rules
@@ -17,11 +19,10 @@ module FoodCritic
17
19
  def check(cookbook_path)
18
20
  warnings = []
19
21
  files_to_process(cookbook_path).each do |file|
20
- ast = Ripper::SexpBuilder.new(IO.read(file)).parse
22
+ ast = read_file(file)
21
23
  @rules.each do |rule|
22
- rule.recipe.yield(ast).each do |match|
23
- warnings << Warning.new(rule, match.merge({:filename => file}))
24
- end
24
+ matches = rule.recipe.yield(ast, File.expand_path(file))
25
+ matches.each{|match| warnings << Warning.new(rule, match.merge({:filename => file}))} unless matches.nil?
25
26
  end
26
27
  end
27
28
  Review.new(warnings)
@@ -1,48 +1,25 @@
1
1
  rule "FC002", "Avoid string interpolation where not required" do
2
2
  description "When setting a resource value avoid string interpolation where not required."
3
3
  recipe do |ast|
4
- matches = []
5
- self.ast(:string_literal, ast).each do |literal|
6
- embed_expr = self.ast(:string_embexpr, literal)
7
- if embed_expr.size == 1
8
- literal[1].reject! { |expr| expr == embed_expr.first }
9
- if self.ast(:@tstring_content, literal).empty?
10
- self.ast(:@ident, embed_expr).map { |ident| ident.flatten.drop(1) }.each do |ident|
11
- matches << {:matched => ident[0], :line => ident[1], :column => ident[2]}
12
- end
13
- end
14
- end
15
- end
16
- matches
4
+ ast.xpath(%q{//string_literal[count(descendant::string_embexpr) = 1 and
5
+ count(string_add/tstring_content|string_add/string_add/tstring_content) = 0]}).map{|str| match(str)}
17
6
  end
18
7
  end
19
8
 
20
9
  rule "FC003", "Check whether you are running with chef server before using server-specific features" do
21
10
  description "Ideally your cookbooks should be usable without requiring chef server."
22
11
  recipe do |ast|
23
- matches = []
24
- function_calls = self.ast(:@ident, self.ast(:fcall, ast)).map { |fcall| fcall.drop(1).flatten }
25
- searches = function_calls.find_all { |fcall| fcall.first == 'search' }
26
- unless searches.empty? || checks_for_chef_solo?(ast)
27
- searches.each { |s| matches << {:matched => s[0], :line => s[1], :column => s[2]} }
28
- end
29
- matches
12
+ checks_for_chef_solo?(ast) ? [] : searches(ast).map{|s| match(s)}
30
13
  end
31
14
  end
32
15
 
33
16
  rule "FC004", "Use a service resource to start and stop services" do
34
17
  description "Avoid use of execute to control services - use the service resource instead."
35
18
  recipe do |ast|
36
- matches = []
37
19
  find_resources(ast, 'execute').find_all do |cmd|
38
- cmd_str = resource_attribute('command', cmd)
39
- cmd_str = resource_name(cmd) if cmd_str.nil?
20
+ cmd_str = (resource_attribute('command', cmd) || resource_name(cmd)).to_s
40
21
  cmd_str.include?('/etc/init.d') || cmd_str.start_with?('service ') || cmd_str.start_with?('/sbin/service ')
41
- end.each do |service_cmd|
42
- exec = ast(:@ident, service_cmd).first.drop(1).flatten
43
- matches << {:matched => exec[0], :line => exec[1], :column => exec[2]}
44
- end
45
- matches
22
+ end.map{|cmd| match(cmd)}
46
23
  end
47
24
  end
48
25
 
@@ -54,10 +31,30 @@ rule "FC005", "Avoid repetition of resource declarations" do
54
31
  resource_attributes_by_type(ast).each do |type, resource_atts|
55
32
  sorted_atts = resource_atts.map{|atts| atts.to_a.sort{|x,y| x.first.to_s <=> y.first.to_s }}
56
33
  if sorted_atts.all?{|att| (att - sorted_atts.inject{|atts,a| atts & a}).length == 1}
57
- first_resource = ast(:@ident, find_resources(ast, type).first).first[2]
58
- matches << {:matched => type, :line => first_resource[0], :column => first_resource[1]}
34
+ matches << match(find_resources(ast, type).first)
59
35
  end
60
36
  end
61
37
  matches
62
38
  end
63
- end
39
+ end
40
+
41
+ rule "FC006", "Mode should be quoted or fully specified when setting file permissions" do
42
+ description "Not quoting mode when setting permissions can lead to incorrect permissions being set."
43
+ recipe do |ast|
44
+ ast.xpath(%q{//ident[@value='mode']/parent::command/descendant::int[string-length(@value) < 4]/
45
+ ancestor::method_add_block}).map{|resource| match(resource)}
46
+ end
47
+ end
48
+
49
+ rule "FC007", "Ensure recipe dependencies are reflected in cookbook metadata" do
50
+ description "You are including a recipe that is not in the current cookbook and not defined as a dependency in your cookbook metadata."
51
+ recipe do |ast,filename|
52
+ metadata_path = File.join(File.dirname(filename), '..', 'metadata.rb')
53
+ next unless File.exists? metadata_path
54
+ undeclared = included_recipes(ast).keys.map{|recipe|recipe.split('::').first} - [cookbook_name(filename)] -
55
+ declared_dependencies(read_file(metadata_path))
56
+ included_recipes(ast).map do |recipe, resource|
57
+ match(resource) if undeclared.include?(recipe) || undeclared.any?{|u| recipe.start_with?("#{u}::")}
58
+ end.compact
59
+ end
60
+ end
@@ -1,3 +1,3 @@
1
1
  module FoodCritic
2
- VERSION = '0.2.0'
2
+ VERSION = '0.3.0'
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foodcritic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,8 +9,30 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2011-12-01 00:00:00.000000000 Z
13
- dependencies: []
12
+ date: 2011-12-04 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: nokogiri
16
+ requirement: &2157802560 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 1.5.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *2157802560
25
+ - !ruby/object:Gem::Dependency
26
+ name: xml-simple
27
+ requirement: &2157800260 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ~>
31
+ - !ruby/object:Gem::Version
32
+ version: 1.1.1
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *2157800260
14
36
  description: Lint tool for Opscode Chef cookbooks.
15
37
  email:
16
38
  executables:
@@ -18,13 +40,13 @@ executables:
18
40
  extensions: []
19
41
  extra_rdoc_files: []
20
42
  files:
21
- - lib/foodcritic.rb
22
43
  - lib/foodcritic/domain.rb
44
+ - lib/foodcritic/dsl.rb
45
+ - lib/foodcritic/helpers.rb
23
46
  - lib/foodcritic/linter.rb
24
47
  - lib/foodcritic/rules.rb
25
- - lib/foodcritic/helpers.rb
26
48
  - lib/foodcritic/version.rb
27
- - lib/foodcritic/dsl.rb
49
+ - lib/foodcritic.rb
28
50
  - bin/foodcritic
29
51
  homepage: http://acrmp.github.com/foodcritic
30
52
  licenses:
@@ -47,12 +69,12 @@ required_rubygems_version: !ruby/object:Gem::Requirement
47
69
  version: '0'
48
70
  segments:
49
71
  - 0
50
- hash: 1647657478747687717
72
+ hash: 927165501432344600
51
73
  requirements: []
52
74
  rubyforge_project:
53
75
  rubygems_version: 1.8.10
54
76
  signing_key:
55
77
  specification_version: 3
56
- summary: foodcritic-0.2.0
78
+ summary: foodcritic-0.3.0
57
79
  test_files: []
58
80
  has_rdoc: