theme-check 1.4.0 → 1.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +14 -6
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +42 -0
  5. data/CONTRIBUTING.md +58 -0
  6. data/Gemfile +3 -0
  7. data/config/default.yml +3 -0
  8. data/docs/checks/deprecated_global_app_block_type.md +65 -0
  9. data/docs/flamegraph.svg +18488 -0
  10. data/lib/theme_check/analyzer.rb +5 -0
  11. data/lib/theme_check/asset_file.rb +13 -2
  12. data/lib/theme_check/check.rb +1 -1
  13. data/lib/theme_check/checks/asset_size_css.rb +15 -0
  14. data/lib/theme_check/checks/asset_size_css_stylesheet_tag.rb +18 -1
  15. data/lib/theme_check/checks/convert_include_to_render.rb +2 -1
  16. data/lib/theme_check/checks/deprecated_global_app_block_type.rb +57 -0
  17. data/lib/theme_check/checks/liquid_tag.rb +1 -1
  18. data/lib/theme_check/checks/missing_required_template_files.rb +21 -7
  19. data/lib/theme_check/checks/pagination_size.rb +33 -14
  20. data/lib/theme_check/checks/required_directories.rb +3 -1
  21. data/lib/theme_check/checks/space_inside_braces.rb +47 -24
  22. data/lib/theme_check/checks/translation_key_exists.rb +3 -1
  23. data/lib/theme_check/checks/unused_snippet.rb +3 -1
  24. data/lib/theme_check/cli.rb +32 -6
  25. data/lib/theme_check/corrector.rb +23 -10
  26. data/lib/theme_check/file_system_storage.rb +13 -2
  27. data/lib/theme_check/html_node.rb +4 -4
  28. data/lib/theme_check/html_visitor.rb +20 -8
  29. data/lib/theme_check/in_memory_storage.rb +8 -0
  30. data/lib/theme_check/json_file.rb +9 -4
  31. data/lib/theme_check/json_printer.rb +6 -1
  32. data/lib/theme_check/language_server/document_link_provider.rb +2 -1
  33. data/lib/theme_check/language_server/handler.rb +16 -11
  34. data/lib/theme_check/language_server/server.rb +11 -13
  35. data/lib/theme_check/language_server/uri_helper.rb +37 -0
  36. data/lib/theme_check/language_server.rb +1 -0
  37. data/lib/theme_check/node.rb +118 -11
  38. data/lib/theme_check/offense.rb +26 -0
  39. data/lib/theme_check/position.rb +27 -16
  40. data/lib/theme_check/position_helper.rb +13 -15
  41. data/lib/theme_check/printer.rb +9 -5
  42. data/lib/theme_check/regex_helpers.rb +1 -15
  43. data/lib/theme_check/remote_asset_file.rb +4 -0
  44. data/lib/theme_check/template.rb +5 -19
  45. data/lib/theme_check/template_rewriter.rb +57 -0
  46. data/lib/theme_check/theme_file.rb +18 -1
  47. data/lib/theme_check/version.rb +1 -1
  48. data/lib/theme_check.rb +1 -0
  49. data/theme-check.gemspec +1 -0
  50. metadata +21 -2
@@ -86,6 +86,11 @@ module ThemeCheck
86
86
  def correct_offenses
87
87
  if @auto_correct
88
88
  offenses.each(&:correct)
89
+ end
90
+ end
91
+
92
+ def write_corrections
93
+ if @auto_correct
89
94
  @theme.liquid.each(&:write)
90
95
  @theme.json.each(&:write)
91
96
  end
@@ -9,10 +9,21 @@ module ThemeCheck
9
9
  @content = nil
10
10
  end
11
11
 
12
- alias_method :content, :source
12
+ def rewriter
13
+ @rewriter ||= TemplateRewriter.new(@relative_path, source)
14
+ end
15
+
16
+ def write
17
+ content = rewriter.to_s
18
+ if source != content
19
+ @storage.write(@relative_path, content.gsub("\n", @eol))
20
+ @source = content
21
+ @rewriter = nil
22
+ end
23
+ end
13
24
 
14
25
  def gzipped_size
15
- @gzipped_size ||= Zlib.gzip(content).bytesize
26
+ @gzipped_size ||= Zlib.gzip(source).bytesize
16
27
  end
17
28
 
18
29
  def name
@@ -46,7 +46,7 @@ module ThemeCheck
46
46
  end
47
47
 
48
48
  def severity_value(severity)
49
- SEVERITY_VALUES[severity]
49
+ SEVERITY_VALUES[severity] || -1
50
50
  end
51
51
 
52
52
  def categories(*categories)
@@ -22,5 +22,20 @@ module ThemeCheck
22
22
  node: node
23
23
  )
24
24
  end
25
+
26
+ def href_to_file_size(href)
27
+ # asset_url (+ optional stylesheet_tag) variables
28
+ if href =~ /^#{LIQUID_VARIABLE}$/o && href =~ /asset_url/ && href =~ Liquid::QuotedString
29
+ asset_id = Regexp.last_match(0).gsub(START_OR_END_QUOTE, "")
30
+ asset = @theme.assets.find { |a| a.name.end_with?("/" + asset_id) }
31
+ return if asset.nil?
32
+ asset.gzipped_size
33
+
34
+ # remote URLs
35
+ elsif href =~ %r{^(https?:)?//}
36
+ asset = RemoteAssetFile.from_src(href)
37
+ asset.gzipped_size
38
+ end
39
+ end
25
40
  end
26
41
  end
@@ -13,12 +13,29 @@ module ThemeCheck
13
13
  def on_variable(node)
14
14
  used_filters = node.value.filters.map { |name, *_rest| name }
15
15
  return unless used_filters.include?("stylesheet_tag")
16
- file_size = href_to_file_size('{{' + node.markup + '}}')
16
+ file_size = stylesheet_tag_pipeline_to_file_size(node.markup)
17
+ return if file_size.nil?
17
18
  return if file_size <= @threshold_in_bytes
18
19
  add_offense(
19
20
  "CSS on every page load exceeding compressed size threshold (#{@threshold_in_bytes} Bytes).",
20
21
  node: node
21
22
  )
22
23
  end
24
+
25
+ def stylesheet_tag_pipeline_to_file_size(href)
26
+ # asset_url
27
+ if href =~ /asset_url/ && href =~ Liquid::QuotedString
28
+ asset_id = Regexp.last_match(0).gsub(START_OR_END_QUOTE, "")
29
+ asset = @theme.assets.find { |a| a.name.end_with?("/" + asset_id) }
30
+ return if asset.nil?
31
+ asset.gzipped_size
32
+
33
+ # remote URLs
34
+ elsif href =~ %r{(https?:)?//[^'"]+}
35
+ url = Regexp.last_match(0)
36
+ asset = RemoteAssetFile.from_src(url)
37
+ asset.gzipped_size
38
+ end
39
+ end
23
40
  end
24
41
  end
@@ -8,7 +8,8 @@ module ThemeCheck
8
8
 
9
9
  def on_include(node)
10
10
  add_offense("`include` is deprecated - convert it to `render`", node: node) do |corrector|
11
- corrector.replace(node, "render \'#{node.value.template_name_expr}\' ")
11
+ # We need to fix #445 and pass the variables from the context or don't replace at all.
12
+ # corrector.replace(node, "render \'#{node.value.template_name_expr}\' ")
12
13
  end
13
14
  end
14
15
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class DeprecatedGlobalAppBlockType < LiquidCheck
4
+ severity :error
5
+ category :liquid
6
+ doc docs_url(__FILE__)
7
+
8
+ INVALID_GLOBAL_APP_BLOCK_TYPE = "@global"
9
+ VALID_GLOBAL_APP_BLOCK_TYPE = "@app"
10
+
11
+ def on_schema(node)
12
+ schema = JSON.parse(node.value.nodelist.join)
13
+
14
+ if block_types_from(schema).include?(INVALID_GLOBAL_APP_BLOCK_TYPE)
15
+ add_offense(
16
+ "Deprecated '#{INVALID_GLOBAL_APP_BLOCK_TYPE}' block type defined in the schema, use '#{VALID_GLOBAL_APP_BLOCK_TYPE}' block type instead.",
17
+ node: node
18
+ )
19
+ end
20
+ rescue JSON::ParserError
21
+ # Ignored, handled in ValidSchema.
22
+ end
23
+
24
+ def on_case(node)
25
+ if node.value == INVALID_GLOBAL_APP_BLOCK_TYPE
26
+ report_offense(node)
27
+ end
28
+ end
29
+
30
+ def on_condition(node)
31
+ if node.value.right == INVALID_GLOBAL_APP_BLOCK_TYPE || node.value.left == INVALID_GLOBAL_APP_BLOCK_TYPE
32
+ report_offense(node)
33
+ end
34
+ end
35
+
36
+ def on_variable(node)
37
+ if node.value.name == INVALID_GLOBAL_APP_BLOCK_TYPE
38
+ report_offense(node)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def report_offense(node)
45
+ add_offense(
46
+ "Deprecated '#{INVALID_GLOBAL_APP_BLOCK_TYPE}' block type, use '#{VALID_GLOBAL_APP_BLOCK_TYPE}' block type instead.",
47
+ node: node
48
+ )
49
+ end
50
+
51
+ def block_types_from(schema)
52
+ schema.fetch("blocks", []).map do |block|
53
+ block.fetch("type", "")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -13,7 +13,7 @@ module ThemeCheck
13
13
  end
14
14
 
15
15
  def on_tag(node)
16
- if !node.inside_liquid_tag?
16
+ if node.inside_liquid_tag?
17
17
  reset_consecutive_statements
18
18
  # Ignore comments
19
19
  elsif !node.comment?
@@ -9,19 +9,33 @@ module ThemeCheck
9
9
  doc docs_url(__FILE__)
10
10
 
11
11
  REQUIRED_LIQUID_FILES = %w(layout/theme)
12
- REQUIRED_TEMPLATE_FILES = %w(
13
- index product collection cart blog article page list-collections search 404
12
+
13
+ REQUIRED_LIQUID_TEMPLATE_FILES = %w(
14
14
  gift_card customers/account customers/activate_account customers/addresses
15
- customers/login customers/order customers/register customers/reset_password password
16
- )
17
- .map { |file| "templates/#{file}" }
15
+ customers/login customers/order customers/register customers/reset_password
16
+ ).map { |file| "templates/#{file}" }
17
+
18
+ REQUIRED_JSON_TEMPLATE_FILES = %w(
19
+ index product collection cart blog article page list-collections search 404
20
+ password
21
+ ).map { |file| "templates/#{file}" }
22
+
23
+ REQUIRED_TEMPLATE_FILES = (REQUIRED_LIQUID_TEMPLATE_FILES + REQUIRED_JSON_TEMPLATE_FILES)
18
24
 
19
25
  def on_end
20
26
  (REQUIRED_LIQUID_FILES - theme.liquid.map(&:name)).each do |file|
21
- add_offense("'#{file}.liquid' is missing")
27
+ add_offense("'#{file}.liquid' is missing") do |corrector|
28
+ corrector.create(@theme, "#{file}.liquid", "")
29
+ end
22
30
  end
23
31
  (REQUIRED_TEMPLATE_FILES - (theme.liquid + theme.json).map(&:name)).each do |file|
24
- add_offense("'#{file}.liquid' or '#{file}.json' is missing")
32
+ add_offense("'#{file}.liquid' or '#{file}.json' is missing") do |corrector|
33
+ if REQUIRED_LIQUID_TEMPLATE_FILES.include?(file)
34
+ corrector.create(@theme, "#{file}.liquid", "")
35
+ else
36
+ corrector.create(@theme, "#{file}.json", "")
37
+ end
38
+ end
25
39
  end
26
40
  end
27
41
  end
@@ -36,16 +36,32 @@ module ThemeCheck
36
36
  # Ignored, handled in ValidSchema.
37
37
  end
38
38
 
39
+ ##
40
+ # Section settings look like:
41
+ # #<Liquid::VariableLookup:0x00007fd699c50c48 @name="section", @lookups=["settings", "products_per_page"], @command_flags=0>
42
+ def size_is_a_section_setting?(size)
43
+ size.is_a?(Liquid::VariableLookup) &&
44
+ size.name == 'section' &&
45
+ size.lookups.first == 'settings'
46
+ end
47
+
48
+ ##
49
+ # We'll work with either an explicit value, or the default value of the section setting.
50
+ def get_value(size)
51
+ return size if size.is_a?(Numeric)
52
+ return get_setting_default_value(size) if size_is_a_section_setting?(size)
53
+ end
54
+
39
55
  def after_document(_node)
40
56
  @paginations.each_pair do |size, nodes|
41
- numerical_size = if size.is_a?(Numeric)
42
- size
43
- else
44
- get_setting_default_value(size.lookups.last)
45
- end
46
- if numerical_size.nil?
57
+ # Validate presence of default section setting.
58
+ if size_is_a_section_setting?(size) && !get_setting_default_value(size)
47
59
  nodes.each { |node| add_offense("Default pagination size should be defined in the section settings", node: node) }
48
- elsif numerical_size > @max_size || numerical_size < @min_size || !numerical_size.is_a?(Integer)
60
+ end
61
+
62
+ # Validate if size is within range.
63
+ next unless (numerical_size = get_value(size))
64
+ if numerical_size > @max_size || numerical_size < @min_size || !numerical_size.is_a?(Integer)
49
65
  nodes.each { |node| add_offense("Pagination size must be a positive integer between #{@min_size} and #{@max_size}", node: node) }
50
66
  end
51
67
  end
@@ -53,13 +69,16 @@ module ThemeCheck
53
69
 
54
70
  private
55
71
 
56
- def get_setting_default_value(setting_id)
57
- setting = @schema_settings.select { |s| s['id'] == setting_id }
58
- unless setting.empty?
59
- return setting.last['default']
60
- end
61
- # Setting does not exist
62
- nil
72
+ def get_setting_default_value(variable_lookup)
73
+ setting = @schema_settings.select { |s| s['id'] == variable_lookup.lookups.last }
74
+
75
+ # Setting does not exist.
76
+ return nil if setting.empty?
77
+
78
+ default_value = setting.last['default'].to_i
79
+ return nil if default_value == 0
80
+
81
+ default_value
63
82
  end
64
83
  end
65
84
  end
@@ -18,7 +18,9 @@ module ThemeCheck
18
18
  private
19
19
 
20
20
  def add_missing_directories_offense(directory)
21
- add_offense("Theme is missing '#{directory}' directory")
21
+ add_offense("Theme is missing '#{directory}' directory") do |corrector|
22
+ corrector.mkdir(@theme, directory)
23
+ end
22
24
  end
23
25
  end
24
26
  end
@@ -15,52 +15,57 @@ module ThemeCheck
15
15
  return if :assign == node.type_name
16
16
 
17
17
  outside_of_strings(node.markup) do |chunk, chunk_start|
18
- chunk.scan(/([,:|]|==|<>|<=|>=|<|>|!=) +/) do |_match|
18
+ chunk.scan(/([,:|]|==|<>|<=|>=|<|>|!=)( +)/) do |_match|
19
19
  add_offense(
20
20
  "Too many spaces after '#{Regexp.last_match(1)}'",
21
21
  node: node,
22
- markup: Regexp.last_match(0),
23
- node_markup_offset: chunk_start + Regexp.last_match.begin(0)
22
+ markup: Regexp.last_match(2),
23
+ node_markup_offset: chunk_start + Regexp.last_match.begin(2)
24
24
  )
25
25
  end
26
26
  chunk.scan(/([,:|]|==|<>|<=|>=|<\b|>\b|!=)(\S|\z)/) do |_match|
27
27
  add_offense(
28
28
  "Space missing after '#{Regexp.last_match(1)}'",
29
29
  node: node,
30
- markup: Regexp.last_match(0),
30
+ markup: Regexp.last_match(1),
31
31
  node_markup_offset: chunk_start + Regexp.last_match.begin(0),
32
32
  )
33
33
  end
34
- chunk.scan(/ (\||==|<>|<=|>=|<|>|!=)+/) do |_match|
34
+ chunk.scan(/( +)(\||==|<>|<=|>=|<|>|!=)+/) do |_match|
35
35
  add_offense(
36
- "Too many spaces before '#{Regexp.last_match(1)}'",
36
+ "Too many spaces before '#{Regexp.last_match(2)}'",
37
37
  node: node,
38
- markup: Regexp.last_match(0),
39
- node_markup_offset: chunk_start + Regexp.last_match.begin(0)
38
+ markup: Regexp.last_match(1),
39
+ node_markup_offset: chunk_start + Regexp.last_match.begin(1)
40
40
  )
41
41
  end
42
42
  chunk.scan(/(\A|\S)(?<match>\||==|<>|<=|>=|<|\b>|!=)/) do |_match|
43
43
  add_offense(
44
44
  "Space missing before '#{Regexp.last_match(1)}'",
45
45
  node: node,
46
- markup: Regexp.last_match(0),
47
- node_markup_offset: chunk_start + Regexp.last_match.begin(0)
46
+ markup: Regexp.last_match(:match),
47
+ node_markup_offset: chunk_start + Regexp.last_match.begin(:match)
48
48
  )
49
49
  end
50
50
  end
51
51
  end
52
52
 
53
53
  def on_tag(node)
54
- if node.inside_liquid_tag?
55
- markup = if node.whitespace_trimmed?
56
- "-%}"
57
- else
58
- "%}"
59
- end
54
+ unless node.inside_liquid_tag?
60
55
  if node.markup[-1] != " " && node.markup[-1] != "\n"
61
- add_offense("Space missing before '#{markup}'", node: node, markup: node.markup[-1] + markup)
56
+ add_offense(
57
+ "Space missing before '#{node.end_token}'",
58
+ node: node,
59
+ markup: node.markup[-1],
60
+ node_markup_offset: node.markup.size - 1,
61
+ )
62
62
  elsif node.markup =~ /(\n?)( +)\z/m && Regexp.last_match(1) != "\n"
63
- add_offense("Too many spaces before '#{markup}'", node: node, markup: Regexp.last_match(2) + markup)
63
+ add_offense(
64
+ "Too many spaces before '#{node.end_token}'",
65
+ node: node,
66
+ markup: Regexp.last_match(2),
67
+ node_markup_offset: node.markup.size - Regexp.last_match(2).size
68
+ )
64
69
  end
65
70
  end
66
71
  @ignore = true
@@ -73,22 +78,40 @@ module ThemeCheck
73
78
  def on_variable(node)
74
79
  return if @ignore || node.markup.empty?
75
80
  if node.markup[0] != " "
76
- add_offense("Space missing after '{{'", node: node) do |corrector|
81
+ add_offense(
82
+ "Space missing after '#{node.start_token}'",
83
+ node: node,
84
+ markup: node.markup[0]
85
+ ) do |corrector|
77
86
  corrector.insert_before(node, " ")
78
87
  end
79
88
  end
80
89
  if node.markup[-1] != " " && node.markup[-1] != "\n"
81
- add_offense("Space missing before '}}'", node: node) do |corrector|
90
+ add_offense(
91
+ "Space missing before '#{node.end_token}'",
92
+ node: node,
93
+ markup: node.markup[-1],
94
+ node_markup_offset: node.markup.size - 1,
95
+ ) do |corrector|
82
96
  corrector.insert_after(node, " ")
83
97
  end
84
98
  end
85
- if node.markup[0] == " " && node.markup[1] == " "
86
- add_offense("Too many spaces after '{{'", node: node) do |corrector|
99
+ if node.markup =~ /\A( +)/m
100
+ add_offense(
101
+ "Too many spaces after '#{node.start_token}'",
102
+ node: node,
103
+ markup: Regexp.last_match(1),
104
+ ) do |corrector|
87
105
  corrector.replace(node, " #{node.markup.lstrip}")
88
106
  end
89
107
  end
90
- if node.markup[-1] == " " && node.markup[-2] == " "
91
- add_offense("Too many spaces before '}}'", node: node) do |corrector|
108
+ if node.markup =~ /(\n?)( +)\z/m && Regexp.last_match(1) != "\n"
109
+ add_offense(
110
+ "Too many spaces before '#{node.end_token}'",
111
+ node: node,
112
+ markup: Regexp.last_match(2),
113
+ node_markup_offset: node.markup.size - Regexp.last_match(2).size
114
+ ) do |corrector|
92
115
  corrector.replace(node, "#{node.markup.rstrip} ")
93
116
  end
94
117
  end
@@ -29,7 +29,9 @@ module ThemeCheck
29
29
  "'#{key_node.value}' does not have a matching entry in '#{@theme.default_locale_json.relative_path}'",
30
30
  node: node,
31
31
  markup: key_node.value,
32
- )
32
+ ) do |corrector|
33
+ corrector.add_default_translation_key(@theme.default_locale_json, key_node.value.split("."), "TODO")
34
+ end
33
35
  end
34
36
  end
35
37
 
@@ -24,7 +24,9 @@ module ThemeCheck
24
24
 
25
25
  def on_end
26
26
  missing_snippets.each do |template|
27
- add_offense("This template is not used", template: template)
27
+ add_offense("This template is not used", template: template) do |corrector|
28
+ corrector.remove(@theme, template.relative_path.to_s)
29
+ end
28
30
  end
29
31
  end
30
32
 
@@ -49,7 +49,7 @@ module ThemeCheck
49
49
  "Automatically fix offenses"
50
50
  ) { @auto_correct = true }
51
51
  @option_parser.on(
52
- "--fail-level SEVERITY", Check::SEVERITIES,
52
+ "--fail-level SEVERITY", [:crash] + Check::SEVERITIES,
53
53
  "Minimum severity (error|suggestion|style) for exit with error code"
54
54
  ) do |severity|
55
55
  @fail_level = severity.to_sym
@@ -78,6 +78,15 @@ module ThemeCheck
78
78
  "Print Theme Check version"
79
79
  ) { @command = :version }
80
80
 
81
+ if ENV["THEME_CHECK_DEBUG"]
82
+ @option_parser.separator("")
83
+ @option_parser.separator("Debugging:")
84
+ @option_parser.on(
85
+ "--profile",
86
+ "Output a profile to STDOUT compatible with FlameGraph."
87
+ ) { @command = :profile }
88
+ end
89
+
81
90
  @option_parser.separator("")
82
91
  @option_parser.separator(<<~EOS)
83
92
  Description:
@@ -172,7 +181,7 @@ module ThemeCheck
172
181
  puts option_parser.to_s
173
182
  end
174
183
 
175
- def check
184
+ def check(out_stream = STDOUT)
176
185
  STDERR.puts "Checking #{@config.root} ..."
177
186
  storage = ThemeCheck::FileSystemStorage.new(@config.root, ignored_patterns: @config.ignored_patterns)
178
187
  theme = ThemeCheck::Theme.new(storage)
@@ -182,18 +191,35 @@ module ThemeCheck
182
191
  analyzer = ThemeCheck::Analyzer.new(theme, @config.enabled_checks, @config.auto_correct)
183
192
  analyzer.analyze_theme
184
193
  analyzer.correct_offenses
185
- output_with_format(theme, analyzer)
194
+ print_with_format(theme, analyzer, out_stream)
195
+ # corrections are committed after printing so that the
196
+ # source_excerpts are still pointing to the uncorrected source.
197
+ analyzer.write_corrections
186
198
  raise Abort, "" if analyzer.uncorrectable_offenses.any? do |offense|
187
199
  offense.check.severity_value <= Check.severity_value(@fail_level)
188
200
  end
189
201
  end
190
202
 
191
- def output_with_format(theme, analyzer)
203
+ def profile
204
+ require 'ruby-prof-flamegraph'
205
+
206
+ result = RubyProf.profile do
207
+ check(STDERR)
208
+ end
209
+
210
+ # Print a graph profile to text
211
+ printer = RubyProf::FlameGraphPrinter.new(result)
212
+ printer.print(STDOUT, {})
213
+ rescue LoadError
214
+ STDERR.puts "Profiling is only available in development"
215
+ end
216
+
217
+ def print_with_format(theme, analyzer, out_stream)
192
218
  case @format
193
219
  when :text
194
- ThemeCheck::Printer.new.print(theme, analyzer.offenses, @config.auto_correct)
220
+ ThemeCheck::Printer.new(out_stream).print(theme, analyzer.offenses, @config.auto_correct)
195
221
  when :json
196
- ThemeCheck::JsonPrinter.new.print(analyzer.offenses)
222
+ ThemeCheck::JsonPrinter.new(out_stream).print(analyzer.offenses)
197
223
  end
198
224
  end
199
225
  end