goodcheck 2.6.0 → 3.0.2

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.
@@ -1,11 +1,43 @@
1
1
  module Goodcheck
2
2
  class Config
3
+ DEFAULT_EXCLUDE_BINARY = false
4
+
5
+ # https://www.iana.org/assignments/media-types/media-types.xhtml
6
+ BINARY_MIME_TYPES = %w[
7
+ audio
8
+ font
9
+ image
10
+ model
11
+ multipart
12
+ video
13
+ ].to_set.freeze
14
+ BINARY_MIME_FULLTYPES = %w[
15
+ application/gzip
16
+ application/illustrator
17
+ application/pdf
18
+ application/zip
19
+ ].to_set.freeze
20
+
3
21
  attr_reader :rules
4
22
  attr_reader :exclude_paths
23
+ attr_reader :exclude_binary
24
+ alias exclude_binary? exclude_binary
25
+ attr_reader :allowed_severities
26
+ attr_reader :severity_required
27
+ alias severity_required? severity_required
5
28
 
6
- def initialize(rules:, exclude_paths:)
29
+ def initialize(rules:, exclude_paths:, exclude_binary: DEFAULT_EXCLUDE_BINARY, severity: nil)
7
30
  @rules = rules
8
31
  @exclude_paths = exclude_paths
32
+ @exclude_binary = exclude_binary || DEFAULT_EXCLUDE_BINARY
33
+ severity ||= {}
34
+ @allowed_severities = Set.new(severity.fetch(:allow, []))
35
+ @severity_required = severity.fetch(:required, false)
36
+ end
37
+
38
+ def severity_allowed?(severity)
39
+ return true if allowed_severities.empty?
40
+ allowed_severities.include?(severity)
9
41
  end
10
42
 
11
43
  def each_rule(filter:, &block)
@@ -44,5 +76,40 @@ module Goodcheck
44
76
  enum_for(:rules_for_path, path, rules_filter: rules_filter)
45
77
  end
46
78
  end
79
+
80
+ def exclude_path?(path)
81
+ excluded = exclude_paths.any? do |pattern|
82
+ path.fnmatch?(pattern, File::FNM_PATHNAME | File::FNM_EXTGLOB)
83
+ end
84
+
85
+ return true if excluded
86
+ return excluded unless exclude_binary?
87
+ return excluded unless path.file?
88
+
89
+ exclude_file_by_mime_type?(path)
90
+ end
91
+
92
+ private
93
+
94
+ def exclude_file_by_mime_type?(file)
95
+ # NOTE: Lazy load to save memory
96
+ require "marcel"
97
+
98
+ fulltype = Marcel::MimeType.for(file)
99
+ type, subtype = fulltype.split("/")
100
+
101
+ case
102
+ when subtype.end_with?("+xml") # e.g. "image/svg+xml"
103
+ false
104
+ when BINARY_MIME_TYPES.include?(type)
105
+ Goodcheck.logger.debug "Exclude file: #{file} (#{fulltype})"
106
+ true
107
+ when BINARY_MIME_FULLTYPES.include?(fulltype)
108
+ Goodcheck.logger.debug "Exclude file: #{file} (#{fulltype})"
109
+ true
110
+ else
111
+ false
112
+ end
113
+ end
47
114
  end
48
115
  end
@@ -103,7 +103,8 @@ module Goodcheck
103
103
  justification: optional(array_or(string)),
104
104
  glob: optional(glob),
105
105
  pass: optional(array_or(string)),
106
- fail: optional(array_or(string))
106
+ fail: optional(array_or(string)),
107
+ severity: optional(string)
107
108
  )
108
109
 
109
110
  let :negative_rule, object(
@@ -113,14 +114,16 @@ module Goodcheck
113
114
  justification: optional(array_or(string)),
114
115
  glob: optional(glob),
115
116
  pass: optional(array_or(string)),
116
- fail: optional(array_or(string))
117
+ fail: optional(array_or(string)),
118
+ severity: optional(string)
117
119
  )
118
120
 
119
121
  let :nopattern_rule, object(
120
122
  id: string,
121
123
  message: string,
122
124
  justification: optional(array_or(string)),
123
- glob: glob
125
+ glob: glob,
126
+ severity: optional(string)
124
127
  )
125
128
 
126
129
  let :positive_trigger, object(
@@ -163,7 +166,8 @@ module Goodcheck
163
166
  id: string,
164
167
  message: string,
165
168
  justification: optional(array_or(string)),
166
- trigger: array_or(trigger)
169
+ trigger: array_or(trigger),
170
+ severity: optional(string)
167
171
  )
168
172
 
169
173
  let :rule, enum(positive_rule,
@@ -187,14 +191,17 @@ module Goodcheck
187
191
 
188
192
  let :rules, array(rule)
189
193
 
190
- let :import_target, string
191
- let :imports, array(import_target)
192
- let :exclude, array_or(string)
194
+ let :severity, object(
195
+ allow: optional(array(string)),
196
+ required: boolean?
197
+ )
193
198
 
194
199
  let :config, object(
195
200
  rules: optional(rules),
196
- import: optional(imports),
197
- exclude: optional(exclude)
201
+ import: optional(array(string)),
202
+ exclude: optional(array_or(string)),
203
+ exclude_binary: boolean?,
204
+ severity: optional(severity)
198
205
  )
199
206
  end
200
207
 
@@ -214,39 +221,40 @@ module Goodcheck
214
221
 
215
222
  def load
216
223
  Goodcheck.logger.info "Loading configuration: #{path}"
217
- Goodcheck.logger.tagged "#{path}" do
218
- Schema.config.coerce(content)
224
+ Schema.config.coerce(content)
219
225
 
220
- rules = []
226
+ rules = []
221
227
 
222
- load_rules(rules, array(content[:rules]))
223
-
224
- Array(content[:import]).each do |import|
225
- load_import rules, import
226
- end
228
+ load_rules(rules, array(content[:rules]))
227
229
 
228
- exclude_paths = Array(content[:exclude])
229
-
230
- Config.new(rules: rules, exclude_paths: exclude_paths)
230
+ Array(content[:import]).each do |import|
231
+ load_import rules, import
231
232
  end
233
+
234
+ Config.new(
235
+ rules: rules,
236
+ exclude_paths: Array(content[:exclude]),
237
+ exclude_binary: content[:exclude_binary],
238
+ severity: content[:severity]
239
+ )
232
240
  end
233
241
 
234
242
  def load_rules(rules, array)
235
243
  array.each do |hash|
236
244
  rules << load_rule(hash)
245
+ rescue RegexpError => exn
246
+ raise InvalidPattern, "Invalid pattern of the `#{hash.fetch(:id)}` rule in `#{path}`: #{exn.message}"
237
247
  end
238
248
  end
239
249
 
240
250
  def load_import(rules, import)
241
251
  Goodcheck.logger.info "Importing rules from #{import}"
242
252
 
243
- Goodcheck.logger.tagged import do
244
- import_loader.load(import) do |content|
245
- json = JSON.parse(JSON.dump(YAML.load(content, filename: import)), symbolize_names: true)
253
+ import_loader.load(import) do |content, filename|
254
+ json = JSON.parse(JSON.dump(YAML.safe_load(content, filename: filename)), symbolize_names: true)
246
255
 
247
- Schema.rules.coerce json
248
- load_rules(rules, json)
249
- end
256
+ Schema.rules.coerce json
257
+ load_rules(rules, json)
250
258
  end
251
259
  end
252
260
 
@@ -257,8 +265,9 @@ module Goodcheck
257
265
  triggers = retrieve_triggers(hash)
258
266
  justifications = array(hash[:justification])
259
267
  message = hash[:message].chomp
268
+ severity = hash[:severity]
260
269
 
261
- Rule.new(id: id, message: message, justifications: justifications, triggers: triggers)
270
+ Rule.new(id: id, message: message, justifications: justifications, triggers: triggers, severity: severity)
262
271
  end
263
272
 
264
273
  def retrieve_triggers(hash)
@@ -2,5 +2,7 @@ module Goodcheck
2
2
  module ExitStatus
3
3
  EXIT_SUCCESS = 0
4
4
  EXIT_ERROR = 1
5
+ EXIT_MATCH = 2
6
+ EXIT_TEST_FAILED = 3
5
7
  end
6
8
  end
@@ -18,6 +18,19 @@ module Goodcheck
18
18
  end
19
19
  end
20
20
 
21
+ class HTTPGetError < Error
22
+ attr_reader :response
23
+
24
+ def initialize(res)
25
+ super("HTTP GET #{res.uri} => #{res.code} #{res.message}")
26
+ @response = res
27
+ end
28
+
29
+ def error_response?
30
+ response.is_a?(Net::HTTPClientError) || response.is_a?(Net::HTTPServerError)
31
+ end
32
+ end
33
+
21
34
  attr_reader :cache_path
22
35
  attr_reader :expires_in
23
36
  attr_reader :force_download
@@ -31,9 +44,13 @@ module Goodcheck
31
44
  end
32
45
 
33
46
  def load(name, &block)
34
- uri = URI.parse(name)
47
+ uri = begin
48
+ URI.parse(name)
49
+ rescue URI::InvalidURIError
50
+ nil
51
+ end
35
52
 
36
- case uri.scheme
53
+ case uri&.scheme
37
54
  when nil
38
55
  load_file name, &block
39
56
  when "file"
@@ -45,14 +62,20 @@ module Goodcheck
45
62
  end
46
63
  end
47
64
 
48
- def load_file(path)
49
- files = Dir.glob(File.join(config_path.parent.to_path, path), File::FNM_DOTMATCH | File::FNM_EXTGLOB).sort
65
+ def load_file(path, &block)
66
+ files = Pathname.glob(File.join(config_path.parent.to_path, path), File::FNM_DOTMATCH | File::FNM_EXTGLOB).sort
50
67
  if files.empty?
51
68
  raise FileNotFound.new(path)
52
69
  else
53
70
  files.each do |file|
54
71
  Goodcheck.logger.info "Reading file: #{file}"
55
- yield File.read(file)
72
+ if unarchiver.tar_gz?(file)
73
+ unarchiver.tar_gz(file.read) do |content, filename|
74
+ block.call(content, filename)
75
+ end
76
+ else
77
+ block.call(file.read, file.to_path)
78
+ end
56
79
  end
57
80
  end
58
81
  end
@@ -61,7 +84,7 @@ module Goodcheck
61
84
  Digest::SHA2.hexdigest(uri.to_s)
62
85
  end
63
86
 
64
- def load_http(uri)
87
+ def load_http(uri, &block)
65
88
  hash = cache_name(uri)
66
89
  path = cache_path + hash
67
90
 
@@ -87,13 +110,19 @@ module Goodcheck
87
110
  if download
88
111
  path.rmtree if path.exist?
89
112
  Goodcheck.logger.info "Downloading content..."
90
- content = http_get uri
91
- Goodcheck.logger.debug "Downloaded content: #{content[0, 1024].inspect}#{content.size > 1024 ? "..." : ""}"
92
- yield content
93
- write_cache uri, content
113
+ if unarchiver.tar_gz?(uri.path)
114
+ unarchiver.tar_gz(http_get(uri)) do |content, filename|
115
+ block.call(content, filename)
116
+ write_cache "#{uri}/#{filename}", content
117
+ end
118
+ else
119
+ content = http_get(uri)
120
+ block.call(content, uri.path)
121
+ write_cache uri, content
122
+ end
94
123
  else
95
124
  Goodcheck.logger.info "Reading content from cache..."
96
- yield path.read
125
+ block.call(path.read, path.to_path)
97
126
  end
98
127
  end
99
128
 
@@ -106,16 +135,41 @@ module Goodcheck
106
135
  def http_get(uri, limit = 10)
107
136
  raise ArgumentError, "Too many HTTP redirects" if limit == 0
108
137
 
109
- res = Net::HTTP.get_response URI(uri)
110
- case res
111
- when Net::HTTPSuccess
112
- res.body
113
- when Net::HTTPRedirection
114
- location = res['Location']
115
- http_get location, limit - 1
116
- else
117
- raise "Error: HTTP GET #{uri.inspect} #{res.inspect}"
138
+ max_retry_count = 2
139
+ retry_count = 0
140
+ begin
141
+ res = Net::HTTP.get_response URI(uri)
142
+ case res
143
+ when Net::HTTPSuccess
144
+ res.body
145
+ when Net::HTTPRedirection
146
+ location = res['Location']
147
+ http_get location, limit - 1
148
+ else
149
+ raise HTTPGetError.new(res)
150
+ end
151
+ rescue Net::OpenTimeout, HTTPGetError => exn
152
+ if retry_count < max_retry_count && exn.error_response?
153
+ retry_count += 1
154
+ Goodcheck.logger.info "#{retry_count} retry HTTP GET #{exn.response.uri} due to '#{exn.response.code} #{exn.response.message}'..."
155
+ sleep 1
156
+ retry
157
+ else
158
+ raise
159
+ end
118
160
  end
119
161
  end
162
+
163
+ private
164
+
165
+ def unarchiver
166
+ @unarchiver ||=
167
+ begin
168
+ filter = ->(filename) {
169
+ %w[.yml .yaml].include?(File.extname(filename).downcase) && File.basename(filename) != DEFAULT_CONFIG_FILE
170
+ }
171
+ Unarchiver.new(file_filter: filter)
172
+ end
173
+ end
120
174
  end
121
175
  end
@@ -1,15 +1,15 @@
1
1
  module Goodcheck
2
2
  class Issue
3
3
  attr_reader :buffer
4
- attr_reader :range
5
4
  attr_reader :rule
6
5
  attr_reader :text
6
+ attr_reader :range
7
7
 
8
- def initialize(buffer:, range:, rule:, text:)
8
+ def initialize(buffer:, rule:, text: nil, text_begin_pos: nil)
9
9
  @buffer = buffer
10
- @range = range
11
10
  @rule = rule
12
11
  @text = text
12
+ @range = text ? text_begin_pos..(text_begin_pos + text.bytesize - 1) : nil
13
13
  @location = nil
14
14
  end
15
15
 
@@ -1,4 +1,18 @@
1
1
  module Goodcheck
2
+ # In the example below, each attribute is:
3
+ #
4
+ # - start_line: 2
5
+ # - start_column: 3
6
+ # - end_line: 2
7
+ # - end_column: 9
8
+ #
9
+ # @example
10
+ #
11
+ # 1 |
12
+ # 2 | A matched text
13
+ # 3 | ^~~~~~~
14
+ # 3456789
15
+ #
2
16
  class Location
3
17
  attr_reader :start_line
4
18
  attr_reader :start_column
@@ -12,6 +26,14 @@ module Goodcheck
12
26
  @end_column = end_column
13
27
  end
14
28
 
29
+ def one_line?
30
+ start_line == end_line
31
+ end
32
+
33
+ def column_size
34
+ end_column - start_column + 1
35
+ end
36
+
15
37
  def ==(other)
16
38
  other.is_a?(Location) &&
17
39
  other.start_line == start_line &&
@@ -19,5 +41,11 @@ module Goodcheck
19
41
  other.end_line == end_line &&
20
42
  other.end_column == end_column
21
43
  end
44
+
45
+ alias eql? ==
46
+
47
+ def hash
48
+ self.class.hash ^ start_line.hash ^ start_column.hash ^ end_line.hash ^ end_column.hash
49
+ end
22
50
  end
23
51
  end
@@ -1,8 +1,8 @@
1
1
  module Goodcheck
2
2
  def self.logger
3
- @logger ||= ActiveSupport::TaggedLogging.new(Logger.new(STDERR)).tap do |logger|
4
- logger.push_tags VERSION
5
- logger.level = Logger::ERROR
6
- end
3
+ @logger ||= Logger.new(
4
+ STDERR, level: Logger::ERROR,
5
+ formatter: ->(severity, time, progname, msg) { "[#{severity}] #{msg}\n" }
6
+ )
7
7
  end
8
8
  end
@@ -26,7 +26,8 @@ module Goodcheck
26
26
  end_column: location.end_column
27
27
  },
28
28
  message: issue.rule.message,
29
- justifications: issue.rule.justifications
29
+ justifications: issue.rule.justifications,
30
+ severity: issue.rule.severity
30
31
  }
31
32
  end
32
33
  stdout.puts ::JSON.dump(json)
@@ -44,6 +45,10 @@ module Goodcheck
44
45
  def issue(issue)
45
46
  issues << issue
46
47
  end
48
+
49
+ def summary
50
+ # noop
51
+ end
47
52
  end
48
53
  end
49
54
  end