openvox-lint 1.0.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 (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/DOCUMENTATION.md +481 -0
  4. data/LICENSE +83 -0
  5. data/README.md +497 -0
  6. data/bin/openvox-lint +7 -0
  7. data/lib/openvox-lint/check_plugin.rb +147 -0
  8. data/lib/openvox-lint/checks.rb +46 -0
  9. data/lib/openvox-lint/cli.rb +87 -0
  10. data/lib/openvox-lint/configuration.rb +59 -0
  11. data/lib/openvox-lint/lexer.rb +342 -0
  12. data/lib/openvox-lint/linter.rb +72 -0
  13. data/lib/openvox-lint/plugins/checks/arrow_alignment.rb +42 -0
  14. data/lib/openvox-lint/plugins/checks/autoloader_layout.rb +31 -0
  15. data/lib/openvox-lint/plugins/checks/case_without_default.rb +28 -0
  16. data/lib/openvox-lint/plugins/checks/class_inherits_params.rb +13 -0
  17. data/lib/openvox-lint/plugins/checks/documentation.rb +26 -0
  18. data/lib/openvox-lint/plugins/checks/double_quoted_strings.rb +19 -0
  19. data/lib/openvox-lint/plugins/checks/duplicate_params.rb +24 -0
  20. data/lib/openvox-lint/plugins/checks/ensure_first_param.rb +28 -0
  21. data/lib/openvox-lint/plugins/checks/ensure_not_symlink_target.rb +29 -0
  22. data/lib/openvox-lint/plugins/checks/file_mode.rb +33 -0
  23. data/lib/openvox-lint/plugins/checks/hard_tabs.rb +15 -0
  24. data/lib/openvox-lint/plugins/checks/hiera3_function.rb +16 -0
  25. data/lib/openvox-lint/plugins/checks/import_statement.rb +13 -0
  26. data/lib/openvox-lint/plugins/checks/inherits_across_namespaces.rb +27 -0
  27. data/lib/openvox-lint/plugins/checks/leading_zero.rb +22 -0
  28. data/lib/openvox-lint/plugins/checks/legacy_facts.rb +47 -0
  29. data/lib/openvox-lint/plugins/checks/line_length.rb +18 -0
  30. data/lib/openvox-lint/plugins/checks/nested_classes_or_defines.rb +26 -0
  31. data/lib/openvox-lint/plugins/checks/node_name_unquoted.rb +18 -0
  32. data/lib/openvox-lint/plugins/checks/only_variable_string.rb +18 -0
  33. data/lib/openvox-lint/plugins/checks/parameter_order.rb +25 -0
  34. data/lib/openvox-lint/plugins/checks/puppet_url_without_modules.rb +17 -0
  35. data/lib/openvox-lint/plugins/checks/quoted_booleans.rb +16 -0
  36. data/lib/openvox-lint/plugins/checks/relative_classname_inclusion.rb +24 -0
  37. data/lib/openvox-lint/plugins/checks/resource_reference_without_title_capital.rb +21 -0
  38. data/lib/openvox-lint/plugins/checks/selector_inside_resource.rb +15 -0
  39. data/lib/openvox-lint/plugins/checks/single_quote_string_with_variables.rb +16 -0
  40. data/lib/openvox-lint/plugins/checks/space_before_arrow.rb +20 -0
  41. data/lib/openvox-lint/plugins/checks/star_comments.rb +13 -0
  42. data/lib/openvox-lint/plugins/checks/strict_indent.rb +16 -0
  43. data/lib/openvox-lint/plugins/checks/top_scope_facts.rb +19 -0
  44. data/lib/openvox-lint/plugins/checks/trailing_comma.rb +24 -0
  45. data/lib/openvox-lint/plugins/checks/trailing_whitespace.rb +14 -0
  46. data/lib/openvox-lint/plugins/checks/unquoted_file_mode.rb +24 -0
  47. data/lib/openvox-lint/plugins/checks/unquoted_resource_title.rb +13 -0
  48. data/lib/openvox-lint/plugins/checks/variable_contains_dash.rb +15 -0
  49. data/lib/openvox-lint/plugins/checks/variable_is_lowercase.rb +16 -0
  50. data/lib/openvox-lint/plugins/checks/variables_not_enclosed.rb +19 -0
  51. data/lib/openvox-lint/report.rb +86 -0
  52. data/lib/openvox-lint/token.rb +38 -0
  53. data/lib/openvox-lint/version.rb +5 -0
  54. data/lib/openvox-lint.rb +47 -0
  55. metadata +145 -0
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module OpenvoxLint
6
+ # Command-line interface for openvox-lint.
7
+ class CLI
8
+ def initialize(args = ARGV)
9
+ @args = args
10
+ @config = OpenvoxLint.configuration
11
+ end
12
+
13
+ def run
14
+ parse_options
15
+ load_rc_file
16
+ if @list_checks
17
+ list_checks; return 0
18
+ end
19
+ files = @args.empty? ? ['.'] : @args
20
+ linter = Linter.new(configuration: @config)
21
+ linter.run(*files)
22
+ Report.new(@config).format(linter.problems)
23
+ print_summary(linter) unless @config.log_format == 'json'
24
+ linter.exit_code
25
+ end
26
+
27
+ private
28
+
29
+ def parse_options # rubocop:disable Metrics/MethodLength
30
+ @parser = OptionParser.new do |opts|
31
+ opts.banner = "Usage: openvox-lint [options] [file|directory ...]"
32
+ opts.separator ''; opts.separator 'Options:'
33
+ opts.on('--version', 'Display version') { puts "openvox-lint #{VERSION}"; exit 0 }
34
+ opts.on('-f', '--format FORMAT', 'Output format: text json csv github codeclimate') { |f| @config.log_format = f }
35
+ opts.on('--log-format FORMAT', 'Custom log format string') { |f| @config.custom_log_format = f; @config.log_format = 'custom' }
36
+ opts.on('--fix', 'Automatically fix problems') { @config.fix = true }
37
+ opts.on('--fail-on-warnings', 'Exit 1 on warnings') { @config.fail_on_warnings = true }
38
+ opts.on('--no-filename', 'Suppress filename') { @config.with_filename = false }
39
+ opts.on('--no-column', 'Suppress column') { @config.column = false }
40
+ opts.on('--relative', 'Relative paths') { @config.relative = true }
41
+ opts.on('--only-checks CHECKS', 'Comma-separated checks') { |c| @config.only_checks = c.split(',').map { |s| s.strip.to_sym } }
42
+ opts.on('--ignore-paths PATHS', 'Comma-separated globs') { |p| @config.ignore_paths = p.split(',').map(&:strip) }
43
+ opts.on('--list-checks', 'List available checks') { @list_checks = true }
44
+ opts.on('-c', '--config FILE', 'Config file path') { |f| @config.config_file = f }
45
+ end
46
+ remaining = []
47
+ begin
48
+ @parser.order!(@args) { |a| remaining << a }
49
+ rescue OptionParser::InvalidOption => e
50
+ flag = e.args.first
51
+ if flag =~ /\A--no-(.+)-check\z/
52
+ @config.disabled_checks << Regexp.last_match(1).tr('-', '_').to_sym
53
+ else
54
+ $stderr.puts e.message; exit 1
55
+ end
56
+ retry
57
+ end
58
+ @args.replace(remaining)
59
+ end
60
+
61
+ def load_rc_file
62
+ ['.openvox-lint.rc', @config.config_file, File.expand_path('~/.openvox-lint.rc')].each do |path|
63
+ next unless path && File.exist?(path)
64
+ @config.load_from_rc(path); break
65
+ end
66
+ end
67
+
68
+ def list_checks
69
+ puts "Available checks (#{OpenvoxLint.checks.size} total):"; puts ''
70
+ OpenvoxLint.checks.keys.sort.each do |name|
71
+ puts " #{@config.check_enabled?(name) ? '✓' : '✗'} #{name}"
72
+ end
73
+ end
74
+
75
+ def print_summary(linter)
76
+ w = linter.problems.count { |p| p[:kind] == :warning }
77
+ e = linter.problems.count { |p| p[:kind] == :error }
78
+ t = linter.problems.size; f = linter.file_count
79
+ $stderr.puts ''
80
+ if t == 0
81
+ $stderr.puts "✓ #{f} file(s) checked — no problems found."
82
+ else
83
+ $stderr.puts "#{f} file(s) checked — #{t} problem(s) found (#{e} error(s), #{w} warning(s))."
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenvoxLint
4
+ # Holds all runtime configuration.
5
+ class Configuration
6
+ DEFAULTS = {
7
+ log_format: 'text', with_filename: true, fail_on_warnings: false,
8
+ fix: false, only_checks: [], disabled_checks: [],
9
+ ignore_paths: %w[vendor/**/*.pp pkg/**/*.pp spec/**/*.pp],
10
+ config_file: '.openvox-lint.rc', relative: false, column: true,
11
+ custom_log_format: nil,
12
+ }.freeze
13
+
14
+ attr_accessor :log_format, :with_filename, :fail_on_warnings,
15
+ :fix, :only_checks, :disabled_checks, :ignore_paths,
16
+ :config_file, :relative, :column, :custom_log_format
17
+
18
+ def initialize
19
+ DEFAULTS.each do |k, v|
20
+ send(:"#{k}=", v.is_a?(Array) ? v.dup : v)
21
+ end
22
+ end
23
+
24
+ def load_from_rc(path)
25
+ return unless File.exist?(path)
26
+ File.readlines(path).each do |line|
27
+ line = line.strip
28
+ next if line.empty? || line.start_with?('#')
29
+ flag, value = line.split(/\s+/, 2)
30
+ apply_flag(flag, value)
31
+ end
32
+ end
33
+
34
+ def check_enabled?(name)
35
+ name = name.to_sym
36
+ return false if disabled_checks.include?(name)
37
+ return only_checks.include?(name) unless only_checks.empty?
38
+ true
39
+ end
40
+
41
+ private
42
+
43
+ def apply_flag(flag, value)
44
+ case flag
45
+ when '--fix' then self.fix = true
46
+ when '--no-fix' then self.fix = false
47
+ when '--fail-on-warnings' then self.fail_on_warnings = true
48
+ when /\A--no-(.+)-check\z/
49
+ disabled_checks << Regexp.last_match(1).tr('-', '_').to_sym
50
+ when '--only-checks'
51
+ self.only_checks = (value || '').split(',').map { |c| c.strip.to_sym }
52
+ when '--log-format'
53
+ self.log_format = value&.strip || 'text'
54
+ when '--ignore-paths'
55
+ self.ignore_paths = (value || '').split(',').map(&:strip)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenvoxLint
4
+ # Tokenises a Puppet / OpenVox manifest string into an Array of Token
5
+ # objects. The lexer recognises all Puppet 8 / OpenVox 8.x language
6
+ # constructs including heredocs, EPP tags, Deferred/Sensitive types,
7
+ # type aliases, and the full operator set.
8
+ class Lexer
9
+ KEYWORDS = {
10
+ 'and' => :AND, 'application' => :APPLICATION, 'attr' => :ATTR,
11
+ 'case' => :CASE, 'class' => :CLASS, 'consumes' => :CONSUMES,
12
+ 'default' => :DEFAULT, 'define' => :DEFINE, 'else' => :ELSE,
13
+ 'elsif' => :ELSIF, 'false' => :FALSE, 'function' => :FUNCTION,
14
+ 'if' => :IF, 'import' => :IMPORT, 'in' => :IN,
15
+ 'inherits' => :INHERITS, 'node' => :NODE, 'not' => :NOT,
16
+ 'or' => :OR, 'private' => :PRIVATE, 'produces' => :PRODUCES,
17
+ 'site' => :SITE, 'true' => :TRUE, 'type' => :TYPE,
18
+ 'undef' => :UNDEF, 'unless' => :UNLESS,
19
+ }.freeze
20
+
21
+ OPERATORS = [
22
+ ['<<|', :LLCOLLECT], ['|>>', :RRCOLLECT],
23
+ ['<=', :LESSEQUAL], ['>=', :GREATEREQUAL],
24
+ ['==', :ISEQUAL], ['!=', :NOTEQUAL],
25
+ ['=~', :MATCH], ['!~', :NOMATCH],
26
+ ['=>', :FARROW], ['+=', :APPENDS],
27
+ ['+>', :PARROW], ['->', :IN_EDGE],
28
+ ['<-', :OUT_EDGE], ['~>', :IN_EDGE_SUB],
29
+ ['<~', :OUT_EDGE_SUB], ['<<', :LSHIFT],
30
+ ['>>', :RSHIFT], ['<|', :LCOLLECT],
31
+ ['|>', :RCOLLECT],
32
+ ].freeze
33
+
34
+ SINGLE_CHAR = {
35
+ '{' => :LBRACE, '}' => :RBRACE, '(' => :LPAREN, ')' => :RPAREN,
36
+ '[' => :LBRACK, ']' => :RBRACK, ';' => :SEMIC, ',' => :COMMA,
37
+ '.' => :DOT, '@' => :AT, '|' => :PIPE, '+' => :PLUS,
38
+ '-' => :MINUS, '*' => :TIMES, '%' => :MODULO, '!' => :NOT,
39
+ '?' => :QMARK, '\\' => :BACKSLASH, ':' => :COLON,
40
+ '=' => :EQUALS, '>' => :GREATERTHAN, '<' => :LESSTHAN,
41
+ }.freeze
42
+
43
+ REGEX_PREV = %i[
44
+ NODE LBRACE LBRACK LPAREN COMMA EQUALS ISEQUAL NOTEQUAL
45
+ MATCH NOMATCH AND OR NOT IF ELSIF CASE RETURN IN
46
+ ].freeze
47
+
48
+ attr_reader :tokens, :manifest_lines
49
+
50
+ def initialize(code)
51
+ @code = code
52
+ @manifest_lines = code.lines.map(&:chomp)
53
+ @tokens = []
54
+ @line = 1
55
+ @column = 1
56
+ @pos = 0
57
+ tokenise
58
+ link_tokens
59
+ end
60
+
61
+ private
62
+
63
+ def tokenise # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
64
+ until @pos >= @code.length
65
+ case @code[@pos]
66
+ when "\n"
67
+ emit(:NEWLINE, "\n"); @line += 1; @column = 1
68
+ when "\r"
69
+ if @code[@pos + 1] == "\n"
70
+ emit(:NEWLINE, "\r\n", 2); @line += 1; @column = 1
71
+ else
72
+ emit(:NEWLINE, "\r"); @line += 1; @column = 1
73
+ end
74
+ when ' ', "\t" then scan_whitespace
75
+ when '#' then scan_comment
76
+ when '/' then scan_slash
77
+ when "'" then scan_single_quoted_string
78
+ when '"' then scan_double_quoted_string
79
+ when '$' then scan_variable
80
+ when '@'
81
+ if @code[@pos + 1] == '(' || @code[@pos + 1] == '"'
82
+ scan_heredoc
83
+ else
84
+ emit(:AT, '@')
85
+ end
86
+ else
87
+ scan_other
88
+ end
89
+ end
90
+ end
91
+
92
+ def scan_whitespace
93
+ start = @pos; start_col = @column
94
+ @pos += 1; @column += 1
95
+ while @pos < @code.length && (@code[@pos] == ' ' || @code[@pos] == "\t")
96
+ @pos += 1; @column += 1
97
+ end
98
+ tok = prev_non_ws_type == :NEWLINE || @tokens.empty? ? :INDENT : :WHITESPACE
99
+ add_token(tok, @code[start...@pos], @line, start_col)
100
+ end
101
+
102
+ def scan_comment
103
+ start = @pos; start_col = @column
104
+ @pos += 1; @column += 1
105
+ while @pos < @code.length && @code[@pos] != "\n"
106
+ @pos += 1; @column += 1
107
+ end
108
+ add_token(:COMMENT, @code[start...@pos], @line, start_col)
109
+ end
110
+
111
+ def scan_slash
112
+ if @code[@pos + 1] == '*' then scan_ml_comment
113
+ elsif @code[@pos + 1] == '/' then scan_slash_comment
114
+ elsif regex_possible? then scan_regex
115
+ else emit(:DIV, '/')
116
+ end
117
+ end
118
+
119
+ def scan_ml_comment
120
+ start = @pos; start_line = @line; start_col = @column
121
+ @pos += 2; @column += 2
122
+ until @pos >= @code.length
123
+ if @code[@pos] == '*' && @code[@pos + 1] == '/'
124
+ @pos += 2; @column += 2; break
125
+ end
126
+ if @code[@pos] == "\n" then @line += 1; @column = 1
127
+ else @column += 1; end
128
+ @pos += 1
129
+ end
130
+ add_token(:MLCOMMENT, @code[start...@pos], start_line, start_col)
131
+ end
132
+
133
+ def scan_slash_comment
134
+ start = @pos; start_col = @column
135
+ @pos += 2; @column += 2
136
+ while @pos < @code.length && @code[@pos] != "\n"
137
+ @pos += 1; @column += 1
138
+ end
139
+ add_token(:SLASH_COMMENT, @code[start...@pos], @line, start_col)
140
+ end
141
+
142
+ def scan_regex
143
+ start = @pos; start_col = @column
144
+ @pos += 1; @column += 1
145
+ while @pos < @code.length && @code[@pos] != '/'
146
+ if @code[@pos] == '\\'
147
+ @pos += 2; @column += 2
148
+ else
149
+ @pos += 1; @column += 1
150
+ end
151
+ end
152
+ @pos += 1; @column += 1
153
+ add_token(:REGEX, @code[start...@pos], @line, start_col)
154
+ end
155
+
156
+ def scan_single_quoted_string
157
+ start = @pos; start_col = @column
158
+ @pos += 1; @column += 1
159
+ while @pos < @code.length
160
+ if @code[@pos] == '\\'
161
+ @pos += 2; @column += 2
162
+ elsif @code[@pos] == "'"
163
+ @pos += 1; @column += 1; break
164
+ elsif @code[@pos] == "\n"
165
+ @line += 1; @column = 1; @pos += 1
166
+ else
167
+ @pos += 1; @column += 1
168
+ end
169
+ end
170
+ add_token(:SSTRING, @code[start...@pos], @line, start_col)
171
+ end
172
+
173
+ def scan_double_quoted_string
174
+ start = @pos; start_col = @column
175
+ @pos += 1; @column += 1
176
+ has_interp = false
177
+ while @pos < @code.length
178
+ if @code[@pos] == '\\'
179
+ @pos += 2; @column += 2
180
+ elsif @code[@pos] == '$' && @code[@pos + 1] =~ /[a-zA-Z_{:]/
181
+ has_interp = true
182
+ @pos += 1; @column += 1
183
+ while @pos < @code.length && @code[@pos] =~ /[a-zA-Z0-9_:{}]/
184
+ @pos += 1; @column += 1
185
+ end
186
+ elsif @code[@pos] == '"'
187
+ @pos += 1; @column += 1; break
188
+ elsif @code[@pos] == "\n"
189
+ @line += 1; @column = 1; @pos += 1
190
+ else
191
+ @pos += 1; @column += 1
192
+ end
193
+ end
194
+ type = has_interp ? :DQSTRING : :STRING
195
+ add_token(type, @code[start...@pos], @line, start_col)
196
+ end
197
+
198
+ def scan_variable
199
+ start = @pos; start_col = @column
200
+ @pos += 1; @column += 1
201
+ while @pos < @code.length && @code[@pos] =~ /[a-zA-Z0-9_:]/
202
+ @pos += 1; @column += 1
203
+ end
204
+ add_token(:VARIABLE, @code[start...@pos], @line, start_col)
205
+ end
206
+
207
+ def scan_heredoc
208
+ start_col = @column
209
+ tag_match = @code[@pos..].match(/\A@\(("?)(\w+)\1\s*([\/\-|:tsnLru]*)\)/)
210
+ unless tag_match
211
+ emit(:AT, '@'); return
212
+ end
213
+ tag = tag_match[2]; tag_len = tag_match[0].length
214
+ add_token(:HEREDOC_OPEN, @code[@pos, tag_len], @line, start_col)
215
+ @pos += tag_len; @column += tag_len
216
+ # Skip rest of current line
217
+ while @pos < @code.length && @code[@pos] != "\n"
218
+ @pos += 1; @column += 1
219
+ end
220
+ @pos += 1; @line += 1; @column = 1
221
+ # Read heredoc body
222
+ heredoc_content = +''
223
+ until @pos >= @code.length
224
+ line_start = @pos
225
+ while @pos < @code.length && @code[@pos] != "\n"
226
+ @pos += 1; @column += 1
227
+ end
228
+ current_line = @code[line_start...@pos]
229
+ if current_line.strip =~ /\A[-|]?\s*#{Regexp.escape(tag)}\s*\z/
230
+ @pos += 1 if @pos < @code.length; @line += 1; @column = 1; break
231
+ end
232
+ heredoc_content << current_line << "\n"
233
+ @pos += 1; @line += 1; @column = 1
234
+ end
235
+ add_token(:HEREDOC, heredoc_content, @line, start_col)
236
+ end
237
+
238
+ def scan_other # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
239
+ OPERATORS.each do |op, type|
240
+ if @code[@pos, op.length] == op
241
+ emit(type, op, op.length); return
242
+ end
243
+ end
244
+ ch = @code[@pos]
245
+ if SINGLE_CHAR.key?(ch)
246
+ emit(SINGLE_CHAR[ch], ch); return
247
+ end
248
+ if ch =~ /[0-9]/
249
+ scan_number; return
250
+ end
251
+ if ch =~ /[a-zA-Z_]/ || (ch == ':' && @code[@pos + 1] == ':')
252
+ scan_name; return
253
+ end
254
+ emit(:OTHER, ch)
255
+ end
256
+
257
+ def scan_number
258
+ start = @pos; start_col = @column
259
+ if @code[@pos] == '0' && @code[@pos + 1] =~ /[xX]/
260
+ @pos += 2; @column += 2
261
+ while @pos < @code.length && @code[@pos] =~ /[0-9a-fA-F_]/
262
+ @pos += 1; @column += 1
263
+ end
264
+ elsif @code[@pos] == '0' && @code[@pos + 1] =~ /[0-7]/
265
+ @pos += 1; @column += 1
266
+ while @pos < @code.length && @code[@pos] =~ /[0-7_]/
267
+ @pos += 1; @column += 1
268
+ end
269
+ else
270
+ while @pos < @code.length && @code[@pos] =~ /[0-9_]/
271
+ @pos += 1; @column += 1
272
+ end
273
+ if @pos < @code.length && @code[@pos] == '.' && @code[@pos + 1] =~ /[0-9]/
274
+ @pos += 1; @column += 1
275
+ while @pos < @code.length && @code[@pos] =~ /[0-9_]/
276
+ @pos += 1; @column += 1
277
+ end
278
+ end
279
+ if @pos < @code.length && @code[@pos] =~ /[eE]/
280
+ @pos += 1; @column += 1
281
+ if @pos < @code.length && @code[@pos] =~ /[+\-]/
282
+ @pos += 1; @column += 1
283
+ end
284
+ while @pos < @code.length && @code[@pos] =~ /[0-9]/
285
+ @pos += 1; @column += 1
286
+ end
287
+ end
288
+ end
289
+ add_token(:NUMBER, @code[start...@pos], @line, start_col)
290
+ end
291
+
292
+ def scan_name
293
+ start = @pos; start_col = @column
294
+ if @code[@pos] == ':' && @code[@pos + 1] == ':'
295
+ @pos += 2; @column += 2
296
+ end
297
+ while @pos < @code.length && @code[@pos] =~ /[a-zA-Z0-9_]/
298
+ @pos += 1; @column += 1
299
+ if @pos + 1 < @code.length && @code[@pos] == ':' && @code[@pos + 1] == ':'
300
+ @pos += 2; @column += 2
301
+ end
302
+ end
303
+ word = @code[start...@pos]
304
+ if KEYWORDS.key?(word)
305
+ add_token(KEYWORDS[word], word, @line, start_col)
306
+ elsif word =~ /\A[A-Z]/
307
+ add_token(:CLASSREF, word, @line, start_col)
308
+ else
309
+ add_token(:NAME, word, @line, start_col)
310
+ end
311
+ end
312
+
313
+ def emit(type, value, length = nil)
314
+ length ||= value.length
315
+ add_token(type, value, @line, @column)
316
+ @pos += length; @column += length
317
+ end
318
+
319
+ def add_token(type, value, line, column)
320
+ @tokens << Token.new(type, value, line, column)
321
+ end
322
+
323
+ def link_tokens
324
+ @tokens.each_with_index do |tok, i|
325
+ tok.prev_token = i > 0 ? @tokens[i - 1] : nil
326
+ tok.next_token = @tokens[i + 1]
327
+ end
328
+ end
329
+
330
+ def prev_non_ws_type
331
+ @tokens.reverse_each do |t|
332
+ return t.type unless t.type == :WHITESPACE || t.type == :INDENT
333
+ end
334
+ nil
335
+ end
336
+
337
+ def regex_possible?
338
+ return true if @tokens.empty?
339
+ REGEX_PREV.include?(prev_non_ws_type)
340
+ end
341
+ end
342
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenvoxLint
4
+ # Orchestrates the linting of one or more manifest files.
5
+ class Linter
6
+ attr_reader :problems, :file_count
7
+
8
+ def initialize(configuration: OpenvoxLint.configuration)
9
+ @config = configuration
10
+ @problems = []
11
+ @file_count = 0
12
+ end
13
+
14
+ def run(*fileargs)
15
+ files = expand_files(fileargs.flatten)
16
+ files.each { |f| lint_file(f) }
17
+ @problems.sort_by! { |p| [p[:path] || '', p[:line] || 0, p[:column] || 0] }
18
+ end
19
+
20
+ def errors?
21
+ @problems.any? { |p| p[:kind] == :error }
22
+ end
23
+
24
+ def warnings?
25
+ @problems.any? { |p| p[:kind] == :warning }
26
+ end
27
+
28
+ def exit_code
29
+ return 1 if errors?
30
+ return 1 if warnings? && @config.fail_on_warnings
31
+ 0
32
+ end
33
+
34
+ private
35
+
36
+ def lint_file(filepath)
37
+ @file_count += 1
38
+ code = File.read(filepath)
39
+ lexer = Lexer.new(code)
40
+ checker = Checks.new(
41
+ tokens: lexer.tokens, manifest_lines: lexer.manifest_lines,
42
+ fullpath: filepath, configuration: @config,
43
+ )
44
+ @problems.concat(checker.run)
45
+ rescue StandardError => e
46
+ @problems << {
47
+ path: filepath, line: 0, column: 0, kind: :error,
48
+ check: :syntax, message: "Could not parse file: #{e.message}",
49
+ }
50
+ end
51
+
52
+ def expand_files(fileargs)
53
+ files = []
54
+ fileargs.each do |arg|
55
+ if File.directory?(arg)
56
+ files.concat(Dir.glob(File.join(arg, '**', '*.pp')))
57
+ elsif arg.include?('*')
58
+ files.concat(Dir.glob(arg))
59
+ elsif File.file?(arg)
60
+ files << arg
61
+ end
62
+ end
63
+ files.reject { |f| ignored?(f) }.uniq
64
+ end
65
+
66
+ def ignored?(filepath)
67
+ @config.ignore_paths.any? do |pat|
68
+ File.fnmatch?(pat, filepath, File::FNM_PATHNAME | File::FNM_DOTMATCH)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Hash rockets (=>) should be aligned within a resource body.
4
+ OpenvoxLint.new_check(:arrow_alignment) do
5
+ def check
6
+ # Group FARROW tokens by resource (consecutive lines)
7
+ arrows = tokens.select { |t| t.type == :FARROW }
8
+ return if arrows.empty?
9
+
10
+ # Group arrows by their enclosing brace block
11
+ groups = group_arrows(arrows)
12
+ groups.each do |group|
13
+ next if group.size <= 1
14
+ columns = group.map(&:column)
15
+ max_col = columns.max
16
+ group.each do |arrow|
17
+ next if arrow.column == max_col
18
+ notify :warning,
19
+ message: "arrow (=>) on line #{arrow.line} not aligned (column #{arrow.column} vs #{max_col})",
20
+ line: arrow.line,
21
+ column: arrow.column
22
+ end
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def group_arrows(arrows)
29
+ groups = []
30
+ current = [arrows.first]
31
+ arrows[1..].each do |arrow|
32
+ if arrow.line - current.last.line <= 2
33
+ current << arrow
34
+ else
35
+ groups << current
36
+ current = [arrow]
37
+ end
38
+ end
39
+ groups << current unless current.empty?
40
+ groups
41
+ end
42
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Class/define names should match the autoloader file path.
4
+ # e.g. class foo::bar::baz should be in foo/manifests/bar/baz.pp
5
+ OpenvoxLint.new_check(:autoloader_layout) do
6
+ def check
7
+ sem = semantic_tokens
8
+ sem.each_with_index do |tok, i|
9
+ next unless tok.type == :CLASS || tok.type == :DEFINE
10
+ j = i + 1
11
+ next if j >= sem.length
12
+ name_tok = sem[j]
13
+ next unless name_tok.type == :NAME || name_tok.type == :CLASSREF
14
+ name = name_tok.value
15
+ parts = name.split('::')
16
+ next if parts.empty?
17
+ expected_path = if parts.length == 1
18
+ "#{parts[0]}/manifests/init.pp"
19
+ else
20
+ "#{parts[0]}/manifests/#{parts[1..].join('/')}.pp"
21
+ end
22
+ next if fullpath.end_with?(expected_path)
23
+ # Also check with just the filename portion
24
+ expected_file = parts.length == 1 ? 'init.pp' : "#{parts.last}.pp"
25
+ next if filename == expected_file
26
+ notify :warning,
27
+ message: "#{tok.value} '#{name}' not in expected autoloader path '#{expected_path}'",
28
+ line: name_tok.line, column: name_tok.column
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Case statements should have a default case.
4
+ OpenvoxLint.new_check(:case_without_default) do
5
+ def check
6
+ sem = semantic_tokens
7
+ sem.each_with_index do |tok, i|
8
+ next unless tok.type == :CASE
9
+ # Find the matching brace block
10
+ j = i + 1
11
+ j += 1 while j < sem.length && sem[j].type != :LBRACE
12
+ next if j >= sem.length
13
+ depth = 1; k = j + 1; has_default = false
14
+ while k < sem.length && depth > 0
15
+ case sem[k].type
16
+ when :LBRACE then depth += 1
17
+ when :RBRACE then depth -= 1
18
+ when :DEFAULT then has_default = true if depth == 1
19
+ end
20
+ k += 1
21
+ end
22
+ next if has_default
23
+ notify :warning,
24
+ message: 'case statement without a default case',
25
+ line: tok.line, column: tok.column
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Class inheritance is discouraged. Prefer composition (include/contain/require).
4
+ OpenvoxLint.new_check(:class_inherits_params) do
5
+ def check
6
+ tokens.each do |tok|
7
+ next unless tok.type == :INHERITS
8
+ notify :warning,
9
+ message: 'class inheritance is discouraged; use composition (include, contain, require) instead',
10
+ line: tok.line, column: tok.column
11
+ end
12
+ end
13
+ end