polishgeeks-dev-tools 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +22 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.travis.yml +6 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +193 -0
- data/README.md +112 -0
- data/Rakefile +20 -0
- data/config/haml-lint.yml +74 -0
- data/config/rubocop.yml +35 -0
- data/config/yardopts +7 -0
- data/lib/polishgeeks-dev-tools.rb +43 -0
- data/lib/polishgeeks/dev-tools/command/allowed_extensions.rb +62 -0
- data/lib/polishgeeks/dev-tools/command/base.rb +73 -0
- data/lib/polishgeeks/dev-tools/command/brakeman.rb +46 -0
- data/lib/polishgeeks/dev-tools/command/coverage.rb +43 -0
- data/lib/polishgeeks/dev-tools/command/examples_comparator.rb +75 -0
- data/lib/polishgeeks/dev-tools/command/expires_in.rb +74 -0
- data/lib/polishgeeks/dev-tools/command/haml_lint.rb +28 -0
- data/lib/polishgeeks/dev-tools/command/readme.rb +32 -0
- data/lib/polishgeeks/dev-tools/command/rspec.rb +38 -0
- data/lib/polishgeeks/dev-tools/command/rspec_files_names.rb +58 -0
- data/lib/polishgeeks/dev-tools/command/rspec_files_structure.rb +134 -0
- data/lib/polishgeeks/dev-tools/command/rubocop.rb +50 -0
- data/lib/polishgeeks/dev-tools/command/rubycritic.rb +17 -0
- data/lib/polishgeeks/dev-tools/command/simplecov.rb +32 -0
- data/lib/polishgeeks/dev-tools/command/tasks_files_names.rb +76 -0
- data/lib/polishgeeks/dev-tools/command/yard.rb +35 -0
- data/lib/polishgeeks/dev-tools/command/yml_parser.rb +85 -0
- data/lib/polishgeeks/dev-tools/config.rb +91 -0
- data/lib/polishgeeks/dev-tools/hash.rb +24 -0
- data/lib/polishgeeks/dev-tools/logger.rb +63 -0
- data/lib/polishgeeks/dev-tools/output_storer.rb +17 -0
- data/lib/polishgeeks/dev-tools/runner.rb +27 -0
- data/lib/polishgeeks/dev-tools/shell.rb +16 -0
- data/lib/polishgeeks/dev-tools/tasks/dev-tools.rake +15 -0
- data/lib/polishgeeks/dev-tools/version.rb +8 -0
- data/polishgeeks_dev_tools.gemspec +36 -0
- data/spec/lib/polishgeeks-dev-tools_spec.rb +35 -0
- data/spec/lib/polishgeeks/dev-tools/command/allowed_extensions_spec.rb +66 -0
- data/spec/lib/polishgeeks/dev-tools/command/base_spec.rb +127 -0
- data/spec/lib/polishgeeks/dev-tools/command/brakeman_spec.rb +95 -0
- data/spec/lib/polishgeeks/dev-tools/command/coverage_spec.rb +121 -0
- data/spec/lib/polishgeeks/dev-tools/command/examples_comparator_spec.rb +171 -0
- data/spec/lib/polishgeeks/dev-tools/command/expires_in_spec.rb +69 -0
- data/spec/lib/polishgeeks/dev-tools/command/haml_lint_spec.rb +79 -0
- data/spec/lib/polishgeeks/dev-tools/command/readme_spec.rb +38 -0
- data/spec/lib/polishgeeks/dev-tools/command/rspec_files_names_spec.rb +91 -0
- data/spec/lib/polishgeeks/dev-tools/command/rspec_files_structure_spec.rb +262 -0
- data/spec/lib/polishgeeks/dev-tools/command/rspec_spec.rb +63 -0
- data/spec/lib/polishgeeks/dev-tools/command/rubocop_spec.rb +127 -0
- data/spec/lib/polishgeeks/dev-tools/command/rubycritic_spec.rb +27 -0
- data/spec/lib/polishgeeks/dev-tools/command/simplecov_spec.rb +53 -0
- data/spec/lib/polishgeeks/dev-tools/command/tasks_files_names_spec.rb +108 -0
- data/spec/lib/polishgeeks/dev-tools/command/yard_spec.rb +86 -0
- data/spec/lib/polishgeeks/dev-tools/command/yml_parser_spec.rb +104 -0
- data/spec/lib/polishgeeks/dev-tools/config_spec.rb +78 -0
- data/spec/lib/polishgeeks/dev-tools/hash_spec.rb +37 -0
- data/spec/lib/polishgeeks/dev-tools/logger_spec.rb +162 -0
- data/spec/lib/polishgeeks/dev-tools/output_storer_spec.rb +20 -0
- data/spec/lib/polishgeeks/dev-tools/runner_spec.rb +57 -0
- data/spec/lib/polishgeeks/dev-tools/shell_spec.rb +13 -0
- data/spec/lib/polishgeeks/dev-tools/version_spec.rb +7 -0
- data/spec/spec_helper.rb +28 -0
- metadata +330 -0
data/config/rubocop.yml
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
AlignParameters:
|
2
|
+
Enabled: false
|
3
|
+
ClassLength:
|
4
|
+
CountComments: false
|
5
|
+
Max: 200
|
6
|
+
LineLength:
|
7
|
+
Max: 99
|
8
|
+
MethodLength:
|
9
|
+
CountComments: false
|
10
|
+
Max: 15
|
11
|
+
Metrics/AbcSize:
|
12
|
+
Max: 25
|
13
|
+
AllCops:
|
14
|
+
Exclude:
|
15
|
+
- bin/**/*
|
16
|
+
- db/**/*
|
17
|
+
- .gemspec/**/*
|
18
|
+
- .bundle/**/*
|
19
|
+
- vendor/**/*
|
20
|
+
- config/**/*
|
21
|
+
- script/**/*
|
22
|
+
- !ruby/regexp /old_and_unused\.rb$/
|
23
|
+
- !ruby/regexp /polishgeeks-[^\/]{2,}\.rb/
|
24
|
+
Include:
|
25
|
+
- '**/Rakefile'
|
26
|
+
- config.ru
|
27
|
+
- lib/tasks/**/*.rake
|
28
|
+
- lib/tasks/**/*.rb
|
29
|
+
- lib/capistrano/**/*.rb
|
30
|
+
- lib/capistrano/**/*.cap
|
31
|
+
Style/MultilineOperationIndentation:
|
32
|
+
EnforcedStyle: indented
|
33
|
+
SupportedStyles:
|
34
|
+
- aligned
|
35
|
+
- indented
|
data/config/yardopts
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'yard'
|
3
|
+
require 'pry'
|
4
|
+
require 'fileutils'
|
5
|
+
require 'timecop'
|
6
|
+
require 'faker'
|
7
|
+
require 'ostruct'
|
8
|
+
|
9
|
+
base_path = File.dirname(__FILE__) + '/polishgeeks/dev-tools/*.rb'
|
10
|
+
Dir[base_path].each { |file| require file }
|
11
|
+
|
12
|
+
module PolishGeeks
|
13
|
+
module DevTools
|
14
|
+
# This is just an alias so we can use it from DevTools directly
|
15
|
+
# @return [PolishGeeks::DevTools::Config.config]
|
16
|
+
def self.config
|
17
|
+
Config.config
|
18
|
+
end
|
19
|
+
|
20
|
+
# @return [String] root path of this gem
|
21
|
+
def self.gem_root
|
22
|
+
File.expand_path('../..', __FILE__)
|
23
|
+
end
|
24
|
+
|
25
|
+
# @return [String] app root path
|
26
|
+
def self.app_root
|
27
|
+
File.dirname(ENV['BUNDLE_GEMFILE'])
|
28
|
+
end
|
29
|
+
|
30
|
+
# Sets up the whole configuration
|
31
|
+
# @param [Block] block
|
32
|
+
def self.setup(&block)
|
33
|
+
Config.setup(&block)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
require 'polishgeeks/dev-tools/command/base'
|
39
|
+
|
40
|
+
commands_path = File.dirname(__FILE__) + '/polishgeeks/dev-tools/command/*.rb'
|
41
|
+
Dir[commands_path].each { |file| require file }
|
42
|
+
|
43
|
+
load 'polishgeeks/dev-tools/tasks/dev-tools.rake'
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module PolishGeeks
|
2
|
+
module DevTools
|
3
|
+
module Command
|
4
|
+
# Checking config directory that all files are allowed
|
5
|
+
class AllowedExtensions < Base
|
6
|
+
self.type = :validator
|
7
|
+
|
8
|
+
# List of allowed extensions of files
|
9
|
+
ALLOWED_EXTENSIONS = %w(
|
10
|
+
rb
|
11
|
+
yml
|
12
|
+
rb.example
|
13
|
+
yml.example
|
14
|
+
)
|
15
|
+
|
16
|
+
# Executes this command
|
17
|
+
# @return [Array] command output array with list of
|
18
|
+
# not allowed files in config directory
|
19
|
+
def execute
|
20
|
+
results = Dir[config_path]
|
21
|
+
|
22
|
+
@output = results
|
23
|
+
.flatten
|
24
|
+
.map { |line| line.gsub!(PolishGeeks::DevTools.app_root + '/config/', '') }
|
25
|
+
.uniq
|
26
|
+
@output.delete_if do |line|
|
27
|
+
ALLOWED_EXTENSIONS.any? { |allow| line =~ /^.*\.#{allow}$/i }
|
28
|
+
end
|
29
|
+
|
30
|
+
@output
|
31
|
+
end
|
32
|
+
|
33
|
+
# @return [String] label with this validator description
|
34
|
+
def label
|
35
|
+
'Allowed Extensions'
|
36
|
+
end
|
37
|
+
|
38
|
+
# @return [String] error message that will be displayed if something
|
39
|
+
# goes wrong
|
40
|
+
def error_message
|
41
|
+
err = 'Following files are not allowed in config directory:'
|
42
|
+
err << "\n\n"
|
43
|
+
err << @output.join("\n")
|
44
|
+
err << "\n"
|
45
|
+
end
|
46
|
+
|
47
|
+
# @return [Boolean] true if all files in config directory
|
48
|
+
# have correct extension
|
49
|
+
def valid?
|
50
|
+
@output.empty?
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
# @return [String] config path with all files included
|
56
|
+
def config_path
|
57
|
+
"#{File.expand_path(PolishGeeks::DevTools.app_root + '/config')}/*.*"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
module PolishGeeks
|
2
|
+
module DevTools
|
3
|
+
# Module encapsulating all the commands that we use to check/verify code
|
4
|
+
module Command
|
5
|
+
# Base class for all the commands
|
6
|
+
# @abstract Subclass and use
|
7
|
+
class Base
|
8
|
+
# Raised when we specify a framework in which a given command can be executed
|
9
|
+
# and it is not present (not detected)
|
10
|
+
class MissingFramework < StandardError; end
|
11
|
+
|
12
|
+
attr_reader :output
|
13
|
+
# stored_output [PolishGeeks::DevTools::OutputStorer] storer with results of previous
|
14
|
+
# commands (they might use output from previous/other commands)
|
15
|
+
attr_accessor :stored_output
|
16
|
+
|
17
|
+
# Available command types. We have validators that check something
|
18
|
+
# and that should have a 'valid?' method and that check for errors, etc
|
19
|
+
# and generators that are executed to generate some stats, docs, etc
|
20
|
+
TYPES = %i( validator generator )
|
21
|
+
|
22
|
+
class << self
|
23
|
+
attr_accessor :type
|
24
|
+
attr_accessor :framework
|
25
|
+
|
26
|
+
TYPES.each do |type|
|
27
|
+
# @return [Boolean] if it is a given type command
|
28
|
+
define_method :"#{type}?" do
|
29
|
+
self.type == type
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# When we will try to use a given command, we need to check if it requires
|
35
|
+
# a given framework (Rails, Sinatra), if so, then we need to check if it is
|
36
|
+
# present, because without it a given command cannot run
|
37
|
+
def initialize
|
38
|
+
ensure_framework_if_required
|
39
|
+
end
|
40
|
+
|
41
|
+
# @raise [NotImplementedError] this should be implemented in a subclass
|
42
|
+
def execute
|
43
|
+
fail NotImplementedError
|
44
|
+
end
|
45
|
+
|
46
|
+
# @raise [NotImplementedError] this should be implemented in a subclass
|
47
|
+
# if it is a validator type (or no implementation required when
|
48
|
+
# it is a validator)
|
49
|
+
def valid?
|
50
|
+
fail NotImplementedError
|
51
|
+
end
|
52
|
+
|
53
|
+
# @return [String] what message should be printed when error occures
|
54
|
+
# @note By default the whole output of an executed command will be printed
|
55
|
+
def error_message
|
56
|
+
output
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
# Checks if a framework is required for a given command, and if so, it wont
|
62
|
+
# allow to execute this command if it is not present
|
63
|
+
# @raise [PolishGeeks::DevTools::Command::Base::MissingFramework] if req framework missing
|
64
|
+
def ensure_framework_if_required
|
65
|
+
return unless self.class.framework
|
66
|
+
return if PolishGeeks::DevTools.config.public_send(:"#{self.class.framework}?")
|
67
|
+
|
68
|
+
fail MissingFramework, self.class.framework
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module PolishGeeks
|
2
|
+
module DevTools
|
3
|
+
module Command
|
4
|
+
# A static analysis security vulnerability scanner for Ruby on Rails applications
|
5
|
+
# @see https://github.com/presidentbeef/brakeman
|
6
|
+
class Brakeman < Base
|
7
|
+
self.type = :validator
|
8
|
+
self.framework = :rails
|
9
|
+
|
10
|
+
# Regexps to get some stat info from brakeman output
|
11
|
+
REGEXPS = {
|
12
|
+
controllers: /Controller.* (\d+)/,
|
13
|
+
models: /Model.* (\d+)/,
|
14
|
+
templates: /Template.* (\d+)/,
|
15
|
+
errors: /Error.* (\d+)/,
|
16
|
+
warnings: /Warning.* (\d+)/
|
17
|
+
}
|
18
|
+
|
19
|
+
# Executes this command
|
20
|
+
# @return [String] command output
|
21
|
+
def execute
|
22
|
+
@output = Shell.new.execute('bundle exec brakeman -q')
|
23
|
+
end
|
24
|
+
|
25
|
+
# @return [Boolean] true if we didn't have any vulnerabilities detected
|
26
|
+
def valid?
|
27
|
+
warnings == 0 && errors == 0
|
28
|
+
end
|
29
|
+
|
30
|
+
# @return [String] label with details bout brakeman scan
|
31
|
+
def label
|
32
|
+
"Brakeman (#{controllers} con, #{models} mod, #{templates} temp)"
|
33
|
+
end
|
34
|
+
|
35
|
+
REGEXPS.each do |name, regexp|
|
36
|
+
# @return [Integer] number of matches for given regexp
|
37
|
+
define_method(name) do
|
38
|
+
output.scan(regexp).flatten.first.to_i
|
39
|
+
end
|
40
|
+
|
41
|
+
private name
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module PolishGeeks
|
2
|
+
module DevTools
|
3
|
+
module Command
|
4
|
+
# Command wrapper for Simple code coverage analysing
|
5
|
+
# It informs us if we didn't reach a proper code coverage level
|
6
|
+
class Coverage < Base
|
7
|
+
self.type = :validator
|
8
|
+
|
9
|
+
# Regexp used to match code coverage level
|
10
|
+
MATCH_REGEXP = /\(\d+.\d+\%\) covered/
|
11
|
+
# Regexp used to match float number from coverage
|
12
|
+
NUMBER_REGEXP = /(\d+[.]\d+)/
|
13
|
+
|
14
|
+
# @return [Float] code coverage level
|
15
|
+
def to_f
|
16
|
+
output[*NUMBER_REGEXP].to_f
|
17
|
+
end
|
18
|
+
|
19
|
+
# Executes this command
|
20
|
+
# @return [String] command output
|
21
|
+
def execute
|
22
|
+
@output = stored_output.rspec[*MATCH_REGEXP]
|
23
|
+
end
|
24
|
+
|
25
|
+
# @return [Boolean] true if code coverage level is higher or equal to expected
|
26
|
+
def valid?
|
27
|
+
to_f >= DevTools.config.simplecov_threshold
|
28
|
+
end
|
29
|
+
|
30
|
+
# @return [String] default label for this command
|
31
|
+
def label
|
32
|
+
"Coverage #{to_f}% covered - #{DevTools.config.simplecov_threshold}% required"
|
33
|
+
end
|
34
|
+
|
35
|
+
# @return [String] message that should be printed when code coverage level is not met
|
36
|
+
def error_message
|
37
|
+
threshold = DevTools.config.simplecov_threshold
|
38
|
+
"Coverage level should more or equal to #{threshold}%. was: #{to_f}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module PolishGeeks
|
2
|
+
module DevTools
|
3
|
+
module Command
|
4
|
+
# Command wrapper for ExamplesComparator rake task
|
5
|
+
# It informs us if our .example files structure is the same as non-example once
|
6
|
+
class ExamplesComparator < Base
|
7
|
+
self.type = :validator
|
8
|
+
|
9
|
+
# Executes this command
|
10
|
+
# @return [String] command output
|
11
|
+
def execute
|
12
|
+
@output = "Comparing yaml structure of example files\n\n"
|
13
|
+
|
14
|
+
Dir[config_path].each do |example_file|
|
15
|
+
dedicated_file = example_file.gsub('.example', '')
|
16
|
+
|
17
|
+
header = compare_header(example_file, dedicated_file)
|
18
|
+
|
19
|
+
if same_key_structure?(example_file, dedicated_file)
|
20
|
+
@output << successful_compare(header)
|
21
|
+
else
|
22
|
+
@output << failed_compare(header)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# @return [Boolean] true if all the example files have the same structure
|
28
|
+
# as non-example once, false otherwise
|
29
|
+
def valid?
|
30
|
+
!output.include?('failed')
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
# @return [String] config path with all the yml.example files included
|
36
|
+
# @note This method is used in Dir[] to get all the example files
|
37
|
+
def config_path
|
38
|
+
"#{File.expand_path(PolishGeeks::DevTools.app_root + '/config')}/**/*.yml.example"
|
39
|
+
end
|
40
|
+
|
41
|
+
# @param [File] example_file which we compare with dedicated one
|
42
|
+
# @param [File] dedicated_file which is compared with example one
|
43
|
+
# @return [Boolean] true if the key structure is the same in both files
|
44
|
+
# otherwise false
|
45
|
+
def same_key_structure?(example_file, dedicated_file)
|
46
|
+
yaml1 = PolishGeeks::DevTools::Hash.new
|
47
|
+
yaml1.merge!(YAML.load_file(example_file))
|
48
|
+
yaml2 = PolishGeeks::DevTools::Hash.new
|
49
|
+
yaml2.merge!(YAML.load_file(dedicated_file))
|
50
|
+
|
51
|
+
yaml1.same_key_structure?(yaml2)
|
52
|
+
end
|
53
|
+
|
54
|
+
# @param [File] example_file which we compare with dedicated one
|
55
|
+
# @param [File] dedicated_file which is compared with example one
|
56
|
+
# @return [String] success/failure (both) message header
|
57
|
+
def compare_header(example_file, dedicated_file)
|
58
|
+
"#{File.basename(example_file)} and #{File.basename(dedicated_file)}"
|
59
|
+
end
|
60
|
+
|
61
|
+
# @param [String] compare_header output message
|
62
|
+
# @return [String] success message for single file
|
63
|
+
def successful_compare(compare_header)
|
64
|
+
"\e[32m success\e[0m - #{compare_header}\n"
|
65
|
+
end
|
66
|
+
|
67
|
+
# @param [String] compare_header output message
|
68
|
+
# @return [String] failed message for single file
|
69
|
+
def failed_compare(compare_header)
|
70
|
+
"\e[31m failed\e[0m - #{compare_header} - structure not equal\n"
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module PolishGeeks
|
2
|
+
module DevTools
|
3
|
+
module Command
|
4
|
+
# Checking Rails cache options especially expires_in
|
5
|
+
class ExpiresIn < Base
|
6
|
+
self.type = :validator
|
7
|
+
|
8
|
+
# List of dirs that we will check for
|
9
|
+
CHECKED_DIRS = %w(
|
10
|
+
app
|
11
|
+
lib
|
12
|
+
)
|
13
|
+
|
14
|
+
# Regexp that we want to use to catch invalid things that occur
|
15
|
+
# instead of expires_in
|
16
|
+
CHECKED_REGEXP = 'expire_in\|expir_in'
|
17
|
+
|
18
|
+
# Executes this command
|
19
|
+
def execute
|
20
|
+
results = CHECKED_DIRS.map do |directory|
|
21
|
+
path = File.join(PolishGeeks::DevTools.app_root, directory)
|
22
|
+
shell_command(path).split("\n")
|
23
|
+
end
|
24
|
+
|
25
|
+
@output = results
|
26
|
+
.flatten
|
27
|
+
.map { |line| line.split(':').first }
|
28
|
+
.map { |line| line.gsub!(PolishGeeks::DevTools.app_root, '') }
|
29
|
+
.uniq
|
30
|
+
|
31
|
+
@output.delete_if do |line|
|
32
|
+
excludes.any? { |exclude| line =~ /#{exclude}/ }
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# @return [String] label with this validator description
|
37
|
+
def label
|
38
|
+
'Expires in'
|
39
|
+
end
|
40
|
+
|
41
|
+
# @return [String] error message
|
42
|
+
def error_message
|
43
|
+
err = 'Following files use expire_in instead of expires_in:'
|
44
|
+
err << "\n\n"
|
45
|
+
err << @output.join("\n")
|
46
|
+
err << "\n"
|
47
|
+
end
|
48
|
+
|
49
|
+
# @return [Boolean] true if not find expire_in
|
50
|
+
def valid?
|
51
|
+
@output.empty?
|
52
|
+
end
|
53
|
+
|
54
|
+
# @return [Array<String>] list of files/directories that should be excluded from checking
|
55
|
+
# @note This should be set in the initializer for this gem in the
|
56
|
+
# place where it is going to be used
|
57
|
+
def excludes
|
58
|
+
DevTools.config.expires_in_files_ignored || []
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
# @param [String] path in which we want to search
|
64
|
+
# @return [String] grep command that should be executed
|
65
|
+
# to find what we search for
|
66
|
+
# @note Not every path must exist, thats why we redirect errors to /dev/null, that way
|
67
|
+
# we can skip all the errors
|
68
|
+
def shell_command(path)
|
69
|
+
`grep -R \"#{CHECKED_REGEXP}\" #{path}/* 2>/dev/null`
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|