chutney 0.5.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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +14 -0
  3. data/.gitignore +14 -0
  4. data/.rubocop.yml +55 -0
  5. data/Dockerfile +9 -0
  6. data/Gemfile +3 -0
  7. data/Guardfile +3 -0
  8. data/LICENSE +22 -0
  9. data/README.md +84 -0
  10. data/Rakefile +51 -0
  11. data/chutney.gemspec +54 -0
  12. data/config/default.yml +58 -0
  13. data/exe/chutney +35 -0
  14. data/lib/chutney/.DS_Store +0 -0
  15. data/lib/chutney/configuration.rb +32 -0
  16. data/lib/chutney/issue.rb +35 -0
  17. data/lib/chutney/linter/avoid_outline_for_single_example.rb +19 -0
  18. data/lib/chutney/linter/avoid_period.rb +19 -0
  19. data/lib/chutney/linter/avoid_scripting.rb +24 -0
  20. data/lib/chutney/linter/background_does_more_than_setup.rb +20 -0
  21. data/lib/chutney/linter/background_requires_multiple_scenarios.rb +18 -0
  22. data/lib/chutney/linter/bad_scenario_name.rb +21 -0
  23. data/lib/chutney/linter/be_declarative.rb +49 -0
  24. data/lib/chutney/linter/file_name_differs_feature_name.rb +27 -0
  25. data/lib/chutney/linter/invalid_file_name.rb +16 -0
  26. data/lib/chutney/linter/invalid_step_flow.rb +41 -0
  27. data/lib/chutney/linter/missing_example_name.rb +23 -0
  28. data/lib/chutney/linter/missing_feature_description.rb +17 -0
  29. data/lib/chutney/linter/missing_feature_name.rb +18 -0
  30. data/lib/chutney/linter/missing_scenario_name.rb +18 -0
  31. data/lib/chutney/linter/missing_test_action.rb +16 -0
  32. data/lib/chutney/linter/missing_verification.rb +16 -0
  33. data/lib/chutney/linter/required_tags_starts_with.rb +16 -0
  34. data/lib/chutney/linter/same_tag_for_all_scenarios.rb +73 -0
  35. data/lib/chutney/linter/tag_collector.rb +10 -0
  36. data/lib/chutney/linter/tag_constraint.rb +35 -0
  37. data/lib/chutney/linter/tag_used_multiple_times.rb +23 -0
  38. data/lib/chutney/linter/too_clumsy.rb +17 -0
  39. data/lib/chutney/linter/too_long_step.rb +17 -0
  40. data/lib/chutney/linter/too_many_different_tags.rb +45 -0
  41. data/lib/chutney/linter/too_many_steps.rb +15 -0
  42. data/lib/chutney/linter/too_many_tags.rb +19 -0
  43. data/lib/chutney/linter/unique_scenario_names.rb +22 -0
  44. data/lib/chutney/linter/unknown_variable.rb +47 -0
  45. data/lib/chutney/linter/unused_variable.rb +47 -0
  46. data/lib/chutney/linter/use_background.rb +82 -0
  47. data/lib/chutney/linter/use_outline.rb +53 -0
  48. data/lib/chutney/linter.rb +164 -0
  49. data/lib/chutney/version.rb +3 -0
  50. data/lib/chutney.rb +131 -0
  51. data/spec/chutney_spec.rb +68 -0
  52. data/spec/configuration_spec.rb +58 -0
  53. data/spec/required_tags_starts_with_spec.rb +74 -0
  54. data/spec/shared_contexts/file_exists.rb +12 -0
  55. data/spec/shared_contexts/gherkin_linter.rb +14 -0
  56. metadata +201 -0
@@ -0,0 +1,53 @@
1
+ require 'amatch'
2
+ require 'chutney/linter'
3
+
4
+ module Chutney
5
+ # service class to lint for using outline
6
+ class UseOutline < Linter
7
+ def lint
8
+ features do |file, feature|
9
+ check_similarity gather_scenarios(file, feature)
10
+ end
11
+ end
12
+
13
+ def check_similarity(scenarios)
14
+ scenarios.product(scenarios) do |lhs, rhs|
15
+ next if lhs == rhs
16
+ next if lhs[:reference] > rhs[:reference]
17
+
18
+ similarity = determine_similarity(lhs[:text], rhs[:text])
19
+ next unless similarity >= 0.95
20
+
21
+ similarity_pct = similarity.round(3) * 100
22
+ references = [lhs[:reference], rhs[:reference]]
23
+ add_error(references, "Scenarios are similar by #{similarity_pct} %, use Background steps to simplify")
24
+ end
25
+ end
26
+
27
+ def determine_similarity(lhs, rhs)
28
+ matcher = Amatch::Jaro.new lhs
29
+ matcher.match rhs
30
+ end
31
+
32
+ def gather_scenarios(file, feature)
33
+ scenarios = []
34
+ return scenarios unless feature.include? :children
35
+
36
+ feature[:children].each do |scenario|
37
+ next unless scenario[:type] == :Scenario
38
+ next unless scenario.include? :steps
39
+ next if scenario[:steps].empty?
40
+
41
+ scenarios.push generate_reference(file, feature, scenario)
42
+ end
43
+ scenarios
44
+ end
45
+
46
+ def generate_reference(file, feature, scenario)
47
+ reference = {}
48
+ reference[:reference] = reference(file, feature, scenario)
49
+ reference[:text] = scenario[:steps].map { |step| render_step(step) }.join ' '
50
+ reference
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,164 @@
1
+ require 'chutney/issue'
2
+
3
+ # gherkin utilities
4
+ module Chutney
5
+ # base class for all linters
6
+ class Linter
7
+ attr_reader :issues
8
+
9
+ def self.descendants
10
+ ObjectSpace.each_object(::Class).select { |klass| klass < self }
11
+ end
12
+
13
+ def initialize
14
+ @issues = []
15
+ @files = {}
16
+ end
17
+
18
+ def features
19
+ @files.each do |file, content|
20
+ feature = content[:feature]
21
+ next if feature.nil?
22
+
23
+ yield(file, feature)
24
+ end
25
+ end
26
+
27
+ def files
28
+ @files.each_key { |file| yield file }
29
+ end
30
+
31
+ def scenarios
32
+ elements do |file, feature, scenario|
33
+ next if scenario[:type] == :Background
34
+
35
+ yield(file, feature, scenario)
36
+ end
37
+ end
38
+
39
+ def filled_scenarios
40
+ scenarios do |file, feature, scenario|
41
+ next unless scenario.include? :steps
42
+ next if scenario[:steps].empty?
43
+
44
+ yield(file, feature, scenario)
45
+ end
46
+ end
47
+
48
+ def steps
49
+ elements do |file, feature, scenario|
50
+ next unless scenario.include? :steps
51
+
52
+ scenario[:steps].each { |step| yield(file, feature, scenario, step) }
53
+ end
54
+ end
55
+
56
+ def backgrounds
57
+ elements do |file, feature, scenario|
58
+ next unless scenario[:type] == :Background
59
+
60
+ yield(file, feature, scenario)
61
+ end
62
+ end
63
+
64
+ def elements
65
+ @files.each do |file, content|
66
+ feature = content[:feature]
67
+ next if feature.nil?
68
+ next unless feature.key? :children
69
+
70
+ feature[:children].each do |scenario|
71
+ yield(file, feature, scenario)
72
+ end
73
+ end
74
+ end
75
+
76
+ def name
77
+ self.class.name.split('::').last
78
+ end
79
+
80
+ def lint_files(files, tags_to_suppress)
81
+ @files = files
82
+ @files = filter_tag(@files, "disable#{name}")
83
+ @files = suppress_tags(@files, tags_to_suppress)
84
+ lint
85
+ end
86
+
87
+ def filter_tag(data, tag)
88
+ return data.reject { |item| tag?(item, tag) }.map { |item| filter_tag(item, tag) } if data.class == Array
89
+ return {} if (data.class == Hash) && (data.include? :feature) && tag?(data[:feature], tag)
90
+ return data unless data.respond_to? :each_pair
91
+
92
+ result = {}
93
+ data.each_pair { |key, value| result[key] = filter_tag(value, tag) }
94
+ result
95
+ end
96
+
97
+ def tag?(data, tag)
98
+ return false if data.class != Hash
99
+ return false unless data.include? :tags
100
+
101
+ data[:tags].map { |item| item[:name] }.include? "@#{tag}"
102
+ end
103
+
104
+ def suppress_tags(data, tags)
105
+ return data.map { |item| suppress_tags(item, tags) } if data.class == Array
106
+ return data unless data.class == Hash
107
+
108
+ result = {}
109
+
110
+ data.each_pair do |key, value|
111
+ value = suppress(value, tags) if key == :tags
112
+ result[key] = suppress_tags(value, tags)
113
+ end
114
+ result
115
+ end
116
+
117
+ def suppress(data, tags)
118
+ data.reject { |item| tags.map { |tag| "@#{tag}" }.include? item[:name] }
119
+ end
120
+
121
+ def lint
122
+ raise 'not implemented'
123
+ end
124
+
125
+ def reference(file, feature = nil, scenario = nil, step = nil)
126
+ return file if feature.nil? || feature[:name].empty?
127
+
128
+ result = "#{file} (#{line(feature, scenario, step)}): #{feature[:name]}"
129
+ result += ".#{scenario[:name]}" unless scenario.nil? || scenario[:name].empty?
130
+ result += " step: #{step[:text]}" unless step.nil?
131
+ result
132
+ end
133
+
134
+ def line(feature, scenario, step)
135
+ line = feature.nil? ? nil : feature[:location][:line]
136
+ line = scenario[:location][:line] unless scenario.nil?
137
+ line = step[:location][:line] unless step.nil?
138
+ line
139
+ end
140
+
141
+ def add_error(references, description = nil)
142
+ @issues.push Error.new(name, references, description)
143
+ end
144
+
145
+ def add_warning(references, description = nil)
146
+ @issues.push Warning.new(name, references, description)
147
+ end
148
+
149
+ def render_step(step)
150
+ value = "#{step[:keyword]}#{step[:text]}"
151
+ value += render_step_argument step[:argument] if step.include? :argument
152
+ value
153
+ end
154
+
155
+ def render_step_argument(argument)
156
+ return "\n#{argument[:content]}" if argument[:type] == :DocString
157
+
158
+ result = argument[:rows].map do |row|
159
+ "|#{row[:cells].map { |cell| cell[:value] }.join '|'}|"
160
+ end.join "\n"
161
+ "\n#{result}"
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,3 @@
1
+ module Chutney
2
+ VERSION = '0.5.0'.freeze
3
+ end
data/lib/chutney.rb ADDED
@@ -0,0 +1,131 @@
1
+ require 'gherkin/parser'
2
+ require 'chutney/linter'
3
+ require 'chutney/linter/avoid_outline_for_single_example'
4
+ require 'chutney/linter/avoid_period'
5
+ require 'chutney/linter/avoid_scripting'
6
+ require 'chutney/linter/background_does_more_than_setup'
7
+ require 'chutney/linter/background_requires_multiple_scenarios'
8
+ require 'chutney/linter/bad_scenario_name'
9
+ require 'chutney/linter/be_declarative'
10
+ require 'chutney/linter/file_name_differs_feature_name'
11
+ require 'chutney/linter/invalid_file_name'
12
+ require 'chutney/linter/invalid_step_flow'
13
+ require 'chutney/linter/missing_example_name'
14
+ require 'chutney/linter/missing_feature_description'
15
+ require 'chutney/linter/missing_feature_name'
16
+ require 'chutney/linter/missing_scenario_name'
17
+ require 'chutney/linter/missing_test_action'
18
+ require 'chutney/linter/missing_verification'
19
+ require 'chutney/linter/required_tags_starts_with'
20
+ require 'chutney/linter/same_tag_for_all_scenarios'
21
+ require 'chutney/linter/tag_used_multiple_times'
22
+ require 'chutney/linter/too_clumsy'
23
+ require 'chutney/linter/too_long_step'
24
+ require 'chutney/linter/too_many_different_tags'
25
+ require 'chutney/linter/too_many_steps'
26
+ require 'chutney/linter/too_many_tags'
27
+ require 'chutney/linter/unique_scenario_names'
28
+ require 'chutney/linter/unknown_variable'
29
+ require 'chutney/linter/unused_variable'
30
+ require 'chutney/linter/use_background'
31
+ require 'chutney/linter/use_outline'
32
+ require 'multi_json'
33
+ require 'set'
34
+ require 'chutney/configuration'
35
+
36
+ module Chutney
37
+ # gherkin linter
38
+ class ChutneyLint
39
+ attr_accessor :verbose
40
+ default_file = File.expand_path('..', __dir__), '**/config', 'default.yml'
41
+ DEFAULT_CONFIG = Dir.glob(File.join(default_file)).first.freeze
42
+ LINTER = Linter.descendants
43
+
44
+ def initialize(path = nil)
45
+ @files = {}
46
+ @linter = []
47
+ @config = Configuration.new path || DEFAULT_CONFIG
48
+ @verbose = false
49
+ end
50
+
51
+ def enabled(linter_name, value)
52
+ @config.config[linter_name]['Enabled'] = value if @config.config.key? linter_name
53
+ end
54
+
55
+ def enable(enabled_linters)
56
+ enabled_linters.each do |linter|
57
+ enabled linter, true
58
+ end
59
+ end
60
+
61
+ def disable(disabled_linters)
62
+ disabled_linters.each do |linter|
63
+ enabled linter, false
64
+ end
65
+ end
66
+
67
+ # Testing feature
68
+ def disable_all
69
+ @config.config.each do |member|
70
+ @config.config[member[0]]['Enabled'] = false
71
+ end
72
+ end
73
+
74
+ def set_linter
75
+ @linter = []
76
+ LINTER.each do |linter|
77
+ new_linter = linter.new
78
+ linter_enabled = @config.config[new_linter.class.name.split('::').last]['Enabled']
79
+ evaluate_members(new_linter) if linter_enabled
80
+ @linter.push new_linter if linter_enabled
81
+ end
82
+ end
83
+
84
+ def evaluate_members(linter)
85
+ @config.config[linter.class.name.split('::').last].each do |member, value|
86
+ next if member.downcase.casecmp('enabled').zero?
87
+
88
+ member = member.downcase.to_sym
89
+ raise 'Member not found! Check the YAML' unless linter.respond_to? member
90
+
91
+ linter.public_send(member, value)
92
+ end
93
+ end
94
+
95
+ def analyze(file)
96
+ @files[file] = parse file
97
+ end
98
+
99
+ def parse(file)
100
+ to_json File.read(file)
101
+ end
102
+
103
+ def report
104
+ issues = @linter.map do |linter|
105
+ linter.lint_files(@files, disable_tags)
106
+ linter.issues
107
+ end.flatten
108
+
109
+ print issues
110
+ return 0 if issues.select { |issue| issue.class == Error }.empty?
111
+
112
+ -1
113
+ end
114
+
115
+ def disable_tags
116
+ LINTER.map { |lint| "disable#{lint.new.class.name.split('::').last}" }
117
+ end
118
+
119
+ def to_json(input)
120
+ parser = Gherkin::Parser.new
121
+ scanner = Gherkin::TokenScanner.new input
122
+
123
+ parser.parse(scanner)
124
+ end
125
+
126
+ def print(issues)
127
+ puts 'There are no issues' if issues.empty? && @verbose
128
+ issues.each { |issue| puts issue.render }
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,68 @@
1
+ require 'rspec'
2
+ require 'chutney'
3
+ require 'chutney/linter/tag_constraint'
4
+ require 'shared_contexts/file_exists'
5
+
6
+ describe Chutney::ChutneyLint do
7
+ it 'should have the constant set' do
8
+ expect(Chutney::ChutneyLint.const_defined?(:LINTER)).to be true
9
+ end
10
+
11
+ subject { Chutney::ChutneyLint.new }
12
+
13
+ describe '#initialize' do
14
+ it 'sets the files instance variable to empty' do
15
+ expect(subject.instance_variable_get(:@files)).to eq({})
16
+ end
17
+
18
+ it 'sets the linter instance variable to empty' do
19
+ expect(subject.instance_variable_get(:@linter).size).to eq(0)
20
+ end
21
+ end
22
+
23
+ describe '#enable' do
24
+ it 'enables the linter passed in' do
25
+ subject.enable ['RequiredTagsStartsWith']
26
+ expect(subject.instance_variable_get(:@config).config).to include('RequiredTagsStartsWith' => { 'Enabled' => true })
27
+ end
28
+ end
29
+
30
+ context 'when user configuration is not present' do
31
+ let(:file) { 'config/default.yml' }
32
+ it 'should load the expected values from the config file' do
33
+ expect(subject.instance_variable_get(:@config).config).to include('AvoidOutlineForSingleExample' => { 'Enabled' => true })
34
+ end
35
+ end
36
+
37
+ context 'when user provided YAML is present' do
38
+ include_context 'a file exists'
39
+ let(:file) { '.chutney.yml' }
40
+ let(:file_content) do
41
+ <<-CONTENT
42
+ ---
43
+ AvoidOutlineForSingleExample:
44
+ Enabled: false
45
+ CONTENT
46
+ end
47
+ it 'should load and merge the expected values from the user config file' do
48
+ expect(subject.instance_variable_get(:@config).config).to include('AvoidOutlineForSingleExample' => { 'Enabled' => false })
49
+ end
50
+ end
51
+
52
+ context 'when linter member value is passed by the user' do
53
+ include_context 'a file exists'
54
+ let(:file) { '.chutney.yml' }
55
+ let(:file_content) do
56
+ <<-CONTENT
57
+ ---
58
+ RequiredTags:
59
+ Enabled: true
60
+ Member: Value
61
+ CONTENT
62
+ end
63
+
64
+ it 'updates the member in the config' do
65
+ expect(subject.instance_variable_get(:@config).config).to include('RequiredTags' => { 'Enabled' => true, 'Member' => 'Value' })
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,58 @@
1
+ require 'rspec'
2
+ require 'chutney/configuration'
3
+ require 'shared_contexts/file_exists'
4
+
5
+ describe Chutney::Configuration do
6
+ subject { Chutney::Configuration.new file }
7
+ let(:file) { 'default.yml' }
8
+
9
+ it 'should do something' do
10
+ expect(subject.config).to eq('')
11
+ end
12
+
13
+ it 'should have a default config path' do
14
+ expect(subject.configuration_path).not_to be nil
15
+ end
16
+ context 'when a empty config file is present' do
17
+ include_context 'a file exists'
18
+ let(:file_content) { '---' }
19
+ it 'should load a file from the config path' do
20
+ expect(subject.config).to eq ''
21
+ end
22
+ end
23
+
24
+ context 'when a non-YAML config file is present' do
25
+ include_context 'a file exists'
26
+ let(:file_content) do
27
+ <<-CONTENT
28
+ foo: [
29
+ ‘bar’, {
30
+ baz: 42
31
+ }
32
+ ]'
33
+ CONTENT
34
+ end
35
+
36
+ it 'should load a file from the config path but fail to parse' do
37
+ expect { subject.load_configuration }.to raise_error
38
+ expect { subject.config }.to raise_error
39
+ end
40
+ end
41
+ context 'when a valid YAML file is present' do
42
+ include_context 'a file exists'
43
+ let(:file_content) do
44
+ <<-CONTENT
45
+ ---
46
+ :parent_key: parent_value
47
+ :child_key: child_value
48
+ CONTENT
49
+ end
50
+ before :each do
51
+ subject.load_configuration
52
+ end
53
+
54
+ it 'should load the values from the config file' do
55
+ expect(subject.config).to eq(parent_key: 'parent_value', child_key: 'child_value')
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,74 @@
1
+ require 'rspec'
2
+ require 'chutney/linter/required_tags_starts_with'
3
+ require 'chutney'
4
+ require 'shared_contexts/gherkin_linter'
5
+
6
+ describe Chutney::RequiredTagsStartsWith do
7
+ let(:linter) { Chutney::ChutneyLint.new }
8
+ let(:file) { 'lint.feature' }
9
+ let(:pattern) { %w[MCC PB] }
10
+ describe '#matcher' do
11
+ it 'should raise an error when pattern is nil' do
12
+ expect { subject.matcher(nil) }.to raise_error('No Tags provided in the YAML')
13
+ end
14
+ it 'should raise an error when pattern is empty' do
15
+ expect { subject.matcher('') }.to output("Required Tags matcher has no value\n").to_stderr
16
+ end
17
+ end
18
+
19
+ describe '#issues' do
20
+ context 'before linting' do
21
+ it 'should have no issue' do
22
+ expect(subject.issues.size).to eq(0)
23
+ end
24
+ end
25
+
26
+ context 'after linting a feature file with valid PB tag at the feature level' do
27
+ include_context 'a gherkin linter'
28
+
29
+ let(:file_content) do
30
+ <<-CONTENT
31
+ @PB
32
+ Feature: Test
33
+ @scenario_tag
34
+ Scenario: A
35
+ CONTENT
36
+ end
37
+ it 'should have no issues' do
38
+ expect(subject.issues.size).to eq(0)
39
+ end
40
+ end
41
+
42
+ context 'after linting a file with a MCC tag at the scenario level' do
43
+ include_context 'a gherkin linter'
44
+ let(:file_content) do
45
+ <<-CONTENT
46
+ @feature_tag
47
+ Feature: Test
48
+ @MCC
49
+ Scenario: A
50
+ CONTENT
51
+ end
52
+
53
+ it 'should have no issues' do
54
+ expect(subject.issues.size).to eq(0)
55
+ end
56
+ end
57
+
58
+ context 'after linting a file with no required tags' do
59
+ include_context 'a gherkin linter'
60
+ let(:file_content) do
61
+ <<-CONTENT
62
+ @feature_tag
63
+ Feature: Test
64
+ @scenario_tag
65
+ Scenario: A
66
+ CONTENT
67
+ end
68
+
69
+ it 'should have issues after linting a file without PB or MCC tags' do
70
+ expect(subject.issues[0].name).to eq(subject.class.name.split('::').last)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,12 @@
1
+ # needs file and file_content defined
2
+ shared_context 'a file exists' do
3
+ before :each do
4
+ File.open(file, 'w') do |f|
5
+ f.write file_content
6
+ end
7
+ end
8
+
9
+ after :each do
10
+ File.delete(file) if File.exist?(file)
11
+ end
12
+ end
@@ -0,0 +1,14 @@
1
+ require 'rspec'
2
+ require_relative 'file_exists'
3
+
4
+ shared_context 'a gherkin linter' do
5
+ include_context 'a file exists'
6
+
7
+ let(:files) { linter.analyze file }
8
+ let(:disable_tags) { linter.disable_tags }
9
+
10
+ before :each do
11
+ subject.instance_variable_set(:@pattern, pattern)
12
+ subject.lint_files({ file: files }, disable_tags)
13
+ end
14
+ end