goodcheck 2.6.1 → 3.0.3

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,15 @@ 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
+ end
29
+
21
30
  attr_reader :cache_path
22
31
  attr_reader :expires_in
23
32
  attr_reader :force_download
@@ -31,9 +40,13 @@ module Goodcheck
31
40
  end
32
41
 
33
42
  def load(name, &block)
34
- uri = URI.parse(name)
43
+ uri = begin
44
+ URI.parse(name)
45
+ rescue URI::InvalidURIError
46
+ nil
47
+ end
35
48
 
36
- case uri.scheme
49
+ case uri&.scheme
37
50
  when nil
38
51
  load_file name, &block
39
52
  when "file"
@@ -45,14 +58,20 @@ module Goodcheck
45
58
  end
46
59
  end
47
60
 
48
- def load_file(path)
49
- files = Dir.glob(File.join(config_path.parent.to_path, path), File::FNM_DOTMATCH | File::FNM_EXTGLOB).sort
61
+ def load_file(path, &block)
62
+ files = Pathname.glob(File.join(config_path.parent.to_path, path), File::FNM_DOTMATCH | File::FNM_EXTGLOB).sort
50
63
  if files.empty?
51
64
  raise FileNotFound.new(path)
52
65
  else
53
66
  files.each do |file|
54
67
  Goodcheck.logger.info "Reading file: #{file}"
55
- yield File.read(file)
68
+ if unarchiver.tar_gz?(file)
69
+ unarchiver.tar_gz(file.read) do |content, filename|
70
+ block.call(content, filename)
71
+ end
72
+ else
73
+ block.call(file.read, file.to_path)
74
+ end
56
75
  end
57
76
  end
58
77
  end
@@ -61,7 +80,7 @@ module Goodcheck
61
80
  Digest::SHA2.hexdigest(uri.to_s)
62
81
  end
63
82
 
64
- def load_http(uri)
83
+ def load_http(uri, &block)
65
84
  hash = cache_name(uri)
66
85
  path = cache_path + hash
67
86
 
@@ -87,13 +106,19 @@ module Goodcheck
87
106
  if download
88
107
  path.rmtree if path.exist?
89
108
  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
109
+ if unarchiver.tar_gz?(uri.path)
110
+ unarchiver.tar_gz(http_get(uri)) do |content, filename|
111
+ block.call(content, filename)
112
+ write_cache "#{uri}/#{filename}", content
113
+ end
114
+ else
115
+ content = http_get(uri)
116
+ block.call(content, uri.path)
117
+ write_cache uri, content
118
+ end
94
119
  else
95
120
  Goodcheck.logger.info "Reading content from cache..."
96
- yield path.read
121
+ block.call(path.read, path.to_path)
97
122
  end
98
123
  end
99
124
 
@@ -106,16 +131,43 @@ module Goodcheck
106
131
  def http_get(uri, limit = 10)
107
132
  raise ArgumentError, "Too many HTTP redirects" if limit == 0
108
133
 
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}"
134
+ max_retry_count = 2
135
+ retry_count = 0
136
+ begin
137
+ res = Net::HTTP.get_response URI(uri)
138
+ case res
139
+ when Net::HTTPSuccess
140
+ res.body
141
+ when Net::HTTPRedirection
142
+ location = res['Location']
143
+ http_get location, limit - 1
144
+ when Net::HTTPClientError, Net::HTTPServerError
145
+ raise HTTPGetError.new(res)
146
+ else
147
+ raise Error, "HTTP GET failed due to #{res.inspect}"
148
+ end
149
+ rescue Net::OpenTimeout, HTTPGetError => exn
150
+ if retry_count < max_retry_count
151
+ retry_count += 1
152
+ Goodcheck.logger.info "Retry ##{retry_count} - HTTP GET #{uri} due to #{exn.inspect}..."
153
+ sleep 1
154
+ retry
155
+ else
156
+ raise
157
+ end
118
158
  end
119
159
  end
160
+
161
+ private
162
+
163
+ def unarchiver
164
+ @unarchiver ||=
165
+ begin
166
+ filter = ->(filename) {
167
+ %w[.yml .yaml].include?(File.extname(filename).downcase) && File.basename(filename) != DEFAULT_CONFIG_FILE
168
+ }
169
+ Unarchiver.new(file_filter: filter)
170
+ end
171
+ end
120
172
  end
121
173
  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