openvox-lint 1.3.2 → 1.3.4

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.
@@ -0,0 +1,229 @@
1
+ # Architecture Review — openvox-lint
2
+
3
+ **Subject:** tip `2facd76` plus this PR's remediations, rebased onto `development` after #6 (Ruby/CI matrix), #7 (fail-closed paths), #9 (release checklist), and #12 (RC/`--fix`/GHA/symlink).
4
+ **Default branch:** `development`
5
+ **Review date:** 2026-09-18 (rebase note same day)
6
+ **Scope:** Architecture-owned findings only. Sibling security/systems work is now on `development` and is not re-litigated here. Remaining sibling-owned drift (`--relative`, issues #3 and #5, fail-closed unknown `-f`) is cited only as context.
7
+
8
+ Severity scale:
9
+
10
+ | Level | Meaning |
11
+ |-------|---------|
12
+ | High | Users or integrators will be misled or lose work if they trust the current contract |
13
+ | Medium | Dead API, silent overwrite, or documented behaviour that does not match code |
14
+ | Low | Comment / changelog / marketing overclaim with no runtime effect |
15
+ | Info | Structural observation; no defect |
16
+
17
+ Each finding lists **evidence** (path + symbol) and a **recommendation**. Items marked **Remediated here** were fixed in this architecture PR.
18
+
19
+ ---
20
+
21
+ ## 1. Lexer / token / check plugin model
22
+
23
+ openvox-lint is a single-process, token-stream linter with no AST and no runtime gems.
24
+
25
+ ```
26
+ CLI / API → Linter → Lexer → Token[] → Checks → Report
27
+ │ │
28
+ └──── Configuration ───┘
29
+ ```
30
+
31
+ | Stage | Symbol | Role |
32
+ |-------|--------|------|
33
+ | CLI | `OpenvoxLint::CLI#run` in `lib/openvox-lint/cli.rb` | Resets the configuration singleton, parses flags, loads one RC file, constructs `Linter` |
34
+ | Orchestrator | `OpenvoxLint::Linter#lint_file` in `lib/openvox-lint/linter.rb` | `File.read` → `Lexer.new` → `Checks#run` → optional `File.write` of mutated `manifest_lines` |
35
+ | Lexer | `OpenvoxLint::Lexer#tokenise` in `lib/openvox-lint/lexer.rb` | Hand-written scanner; emits `Token` objects and links `prev_token` / `next_token` |
36
+ | Token | `OpenvoxLint::Token` in `lib/openvox-lint/token.rb` | `type`, `value`, `line`, `column`, `formatting?` |
37
+ | Registry | `OpenvoxLint.new_check` / `.checks` in `lib/openvox-lint.rb` | `Class.new(CheckPlugin, &block)` stored in a process-global Hash |
38
+ | Runner | `OpenvoxLint::Checks#run` in `lib/openvox-lint/checks.rb` | Instantiates each enabled check, collects problems, optionally calls `#fix_problems` |
39
+ | Plugin base | `OpenvoxLint::CheckPlugin` in `lib/openvox-lint/check_plugin.rb` | `#check`, `#fix`, `notify`, `semantic_tokens`, `resource_indexes`, `class_indexes`, `defined_type_indexes`, `node_indexes`, `title_tokens` |
40
+ | Report | `OpenvoxLint::Report#format` in `lib/openvox-lint/report.rb` | text / json / csv / github / codeclimate / custom |
41
+
42
+ **Built-in load boundary.** After the module is defined, `lib/openvox-lint.rb` does:
43
+
44
+ ```ruby
45
+ Dir[File.join(__dir__, 'openvox-lint', 'plugins', 'checks', '*.rb')].sort.each do |f|
46
+ require f
47
+ end
48
+ ```
49
+
50
+ That glob is this gem's `__dir__` only. There is no second pass over `$LOAD_PATH`, `Gem::Specification`, or a CLI `--load` path.
51
+
52
+ **Fix model.** `CheckPlugin#fix` defaults to `raise OpenvoxLint::NoFix`. Five plugins override it and mutate `@manifest_lines` (chomped copies from the lexer): `trailing_whitespace`, `hard_tabs`, `quoted_booleans`, `double_quoted_strings`, `single_quote_string_with_variables`. `Linter#lint_file` then joins those lines and writes the file. This is intentionally lighter than puppet-lint's token-rewriting `PuppetLint::Data` singleton (`UPDATE_SUMMARY.md`).
53
+
54
+ ### F1. Lexer comment claimed EPP / dedicated type tokens — **Low** — **Remediated here**
55
+
56
+ - **Evidence:** `OpenvoxLint::Lexer` class comment in `lib/openvox-lint/lexer.rb` previously said the lexer recognised "heredocs, EPP tags, Deferred/Sensitive types, type aliases". `Lexer#tokenise` has no `<%` / `%>` branch; `<` is `SINGLE_CHAR[:LESSTHAN]`. `Deferred` / `Sensitive` / `Type` become `:NAME` or `:CLASSREF` via `scan_name`. `CHANGELOG.md` `[1.0.0]` repeated "EPP tag … tokenization". `DOCUMENTATION.md` "Supported Constructs" was already honest (no EPP).
57
+ - **Recommendation:** Keep the class comment scoped to `.pp` constructs. Do not add an EPP scanner unless `.epp` becomes a first-class target (needs tag tokens and EPP parameter lists). Sibling issue #5 owns heredoc terminator behaviour.
58
+
59
+ ### F2. Helpers are depth-aware and worth keeping — **Info**
60
+
61
+ - **Evidence:** `CheckPlugin#compute_resource_indexes` walks `semantic_tokens`, skips `class`/`define`/`node` `NAME {` bodies, and collects `param_tokens` only at brace depth 1. `find_keyword_indexes` and `compute_title_tokens` share the same skip. Regression coverage: `spec/unit/checks_spec.rb` `:unquoted_resource_title`.
62
+ - **Recommendation:** Keep this lighter helper model. Do not port `PuppetLint::Data` unless `#fix` needs token-list rewriting.
63
+
64
+ ---
65
+
66
+ ## 2. Dead APIs
67
+
68
+ ### F3. `respond_to?(:fix_problems)` guard — **Low** — **Remediated here**
69
+
70
+ - **Evidence:** `Checks#run` in `lib/openvox-lint/checks.rb` called `plugin.fix_problems if @configuration.fix && plugin.respond_to?(:fix_problems)`. `CheckPlugin#fix_problems` is defined on the base class (`lib/openvox-lint/check_plugin.rb`). Every registered check is `Class.new(CheckPlugin, &block)`. The guard never skipped.
71
+ - **Recommendation:** Call `#fix_problems` whenever `configuration.fix` is true. `#fix` still raises `NoFix` for unimplemented cases.
72
+
73
+ ### F4. `formatting?` walk inside `compute_resource_indexes` — **Low** — **Remediated here**
74
+
75
+ - **Evidence:** `compute_resource_indexes` assigned `sem = semantic_tokens`, and `semantic_tokens` is `tokens.reject(&:formatting?)`. The backward walk then did `break unless t.formatting?`, which is always false on `sem`. The loop could only inspect `sem[i - 1]`. The same dead pattern was already removed from `duplicate_params` / `parameter_order` (`CHANGELOG.md` `[1.0.8]`).
76
+ - **Recommendation:** Test the previous semantic token only. Do not remove `Token#formatting?` itself — `Checks#inline_ignore?` and several checks (`documentation`, `leading_zero`, `file_mode`, …) still walk the raw `tokens` stream.
77
+
78
+ ### F5. Duplicate `new_check` warning hidden behind `OPENVOX_LINT_DEBUG` — **Medium** — **Remediated here**
79
+
80
+ - **Evidence:** `OpenvoxLint.new_check` in `lib/openvox-lint.rb` overwrote `checks[name]` and printed a warning only when `ENV['OPENVOX_LINT_DEBUG']` was set. `CHANGELOG.md` `[1.0.8]` documented that debug gate. A custom file that reused `:trailing_whitespace` would silently replace the built-in check.
81
+ - **Recommendation:** Always warn on stderr. Keep overwrite (failing would break the spec helper, which `load`s check files into an already-populated registry). A future `--fail-on-duplicate-check` flag is optional.
82
+
83
+ ### F6. `--relative` / `Configuration#relative` is stored and never read — **Medium** — **Deferred (systems sibling)**
84
+
85
+ - **Evidence:** `CLI#parse_options` sets `@config.relative = true` (`lib/openvox-lint/cli.rb`). `Configuration::DEFAULTS` and `attr_accessor :relative` exist (`lib/openvox-lint/configuration.rb`). `Report#format_text` / `#format_github` / `#serialise` emit `p[:path]` as the `fullpath` passed into `Checks` (`lib/openvox-lint/linter.rb`). No reader of `@config.relative`. Documented in README usage, DOCUMENTATION configuration table.
86
+ - **Recommendation:** Sibling owns implement-or-remove. Do not change path logic here.
87
+
88
+ ---
89
+
90
+ ## 3. puppet-lint comparison honesty
91
+
92
+ README and DOCUMENTATION tables previously described puppet-lint 5.x as if it were a frozen, plugin-dependent archive. puppet-lint `main` (fetched 2026-09-18) is maintained.
93
+
94
+ | Previous claim | Evidence it is stale | Remediation |
95
+ |----------------|----------------------|-------------|
96
+ | GitHub Actions output: **No** | puppet-lint README: `--sarif`, [puppet-lint-action](https://github.com/marketplace/actions/puppet-lint-action); MegaLinter help text lists `--sarif`. Env-based `::` annotations exist in the ecosystem (voxpupuli/onceover-codequality#59). | Table now says puppet-lint has SARIF / action / env annotations; openvox-lint has `-f github`. |
97
+ | Code Climate output: **No** | puppet-lint README: `--codeclimate-report-file` and `CODECLIMATE_REPORT_FILE`. | Table now credits both. |
98
+ | Legacy / top-scope facts: **Via plugin** | `lib/puppet-lint/plugins/legacy_facts/legacy_facts.rb` and `top_scope_facts/` ship **in** puppet-lint; README documents YAML legacy-fact checks. | Table now says built-in on both sides. |
99
+ | Built-in checks: **~25** | Core set has grown; count is no longer a stable differentiator. | Table describes the core set plus fact checks instead of a guessed number. |
100
+ | Plugin system: **Yes (compatible)** | puppet-lint has `--load`, `--load-from-puppet`, and `PuppetLint::Plugins.load_from_gems` (`lib/puppet-lint/plugins.rb` walks other gems' `lib/puppet-lint/plugins/**/*.rb`). openvox-lint has none of those. The `new_check` / `notify` names look similar; loading and `#fix` do not. | Tables now say require-your-file only / not drop-in compatible. |
101
+ | `--fix`: unqualified **Yes** | puppet-lint rewrites tokens for many core checks. openvox-lint has five line-based `#fix` methods (grep `def fix` under `lib/openvox-lint/plugins/checks/`). | Tables list the five names. |
102
+
103
+ Still-valid differentiators (with current evidence):
104
+
105
+ - **Hiera 3 functions** and **`import`** as built-in errors — not in puppet-lint's published core check list.
106
+ - **OpenVox** style-guide targeting and `vim-openvox` as the default backend.
107
+ - **Native CSV** (`Report#format_csv`) — puppet-lint help lists json/sarif/codeclimate/log-format, not CSV.
108
+ - **Ruby floor** as declared here (`>= 2.6.0` in `openvox-lint.gemspec`). CI matrices `2.6` and `3.1`–`3.3`. Ruby 2.5 is no longer claimed.
109
+
110
+ ### F7. Comparison tables overclaimed vs current puppet-lint — **Medium** — **Remediated here**
111
+
112
+ - **Recommendation:** Keep the tables dated. Re-check puppet-lint `main` when cutting a release that touches marketing rows. Do not claim "drop-in replacement" in the gemspec without a loading story (the gemspec still says "Drop-in replacement for the archived puppet-lint"; puppet-lint is not archived). Softening that sentence further is optional follow-up.
113
+
114
+ ---
115
+
116
+ ## 4. Design drift vs README / DOCUMENTATION
117
+
118
+ ### F8. Custom-plugin docs promised gem auto-discovery — **High** — **Remediated here**
119
+
120
+ - **Evidence:** README "Writing Custom Checks" said "Place in `lib/openvox-lint/plugins/checks/` and it will be auto-loaded." DOCUMENTATION "Distributing as a Gem" said the same for a third-party gem. The only loader is the `__dir__` glob in `lib/openvox-lint.rb`. `CLI#parse_options` has no `--load`. Contrast puppet-lint `PuppetLint::Plugins.load_from_gems`.
121
+ - **Recommendation (chosen):** Honest docs. A minimal `--load FILE` plus optional `Gem::Specification` walk is small but expands the CLI/security surface (path traversal, load-time code exec) and would collide with sibling CLI work. Revisit only with an explicit product request.
122
+
123
+ ### F9. Check count and `--list-checks` copy — **Low** — **Remediated here** (count + list-checks)
124
+
125
+ - **Evidence:** README project tree said "38 built-in check plugins" while 37 files exist under `lib/openvox-lint/plugins/checks/` (`relative_classname_inclusion` removed in 1.1.0). README "Listing All Available Checks" claimed `--list-checks` prints severity and description; `CLI#list_checks` prints `✓`/`✗` and the name only. DOCUMENTATION header said "5+ checks" for `--fix`; five `#fix` methods exist.
126
+ - **Recommendation:** Keep the badge, tree, and `--fix` counts generated from the same inventory (37 files, 5 fixes). `--list-checks` copy now matches the CLI.
127
+
128
+ ### F10. Documented `--relative`, unused — **Medium** — **Deferred (systems sibling)**
129
+
130
+ See F6. README usage and DOCUMENTATION `Configuration` table still describe the flag because the sibling may implement it.
131
+
132
+ ### F11. Source-update docs pointed at `main` — **Low** — **Remediated here**
133
+
134
+ - **Evidence:** README "Updating / From Source" used `git pull origin main`. Default branch is `development`.
135
+ - **Recommendation:** Pull `development`.
136
+
137
+ ---
138
+
139
+ ## 5. Check extensibility
140
+
141
+ The in-tree DSL is adequate for contributors:
142
+
143
+ 1. Add `lib/openvox-lint/plugins/checks/<name>.rb` calling `OpenvoxLint.new_check(:name)`.
144
+ 2. Implement `#check` (required) and optionally `#fix`.
145
+ 3. Use `notify :warning|:error, message:, line:, column:`.
146
+ 4. Use helpers on `CheckPlugin` rather than re-scanning braces.
147
+
148
+ The **out-of-tree** story was the gap (F8). After this PR:
149
+
150
+ - Documented contract: `require 'openvox-lint'` then `require` your file.
151
+ - Duplicate names always warn (F5).
152
+ - A gem can wrap that require in its own entry point; openvox-lint will not search for it.
153
+
154
+ Remaining extensibility limits (not changed here):
155
+
156
+ | Limit | Evidence | Notes |
157
+ |-------|----------|-------|
158
+ | No `--load` | `CLI#parse_options` | Preferred honest docs over a new flag |
159
+ | No gem discovery | `lib/openvox-lint.rb` loader | Avoids loading every gem's `lib/openvox-lint/plugins/**` |
160
+ | `#fix` is line-based | five plugins mutate `@manifest_lines` | Structural fixes (e.g. `arrow_alignment`) stay future work |
161
+ | Registry is process-global | `OpenvoxLint.checks` | Spec helper must `instance_variable_set(:@checks, nil)` then `load` |
162
+ | No check metadata | `CLI#list_checks` | Name + enabled only; severity lives in docs |
163
+
164
+ ---
165
+
166
+ ## 6. Versioning / release notes
167
+
168
+ ### F12. Semver process is documented; some historical notes overclaim — **Low** — **Partially remediated**
169
+
170
+ - **Evidence:** `CONTRIBUTING.md` "Releasing" requires bumping `OpenvoxLint::VERSION` (`lib/openvox-lint/version.rb`, currently `1.3.3`), `CHANGELOG.md`, and check counts. `[1.3.0]` says "All references now 1.3.1" (version-skid from the 1.3.0→1.3.1 republish; `UPDATE_SUMMARY.md` explains it). `[1.0.0]` claimed EPP tokenization (corrected in this PR). `[1.0.8]` documented the debug-gated duplicate warning (superseded in 1.3.3).
171
+ - **Recommendation:** Add an `[Unreleased]` section for architecture work (done). Do not rewrite unrelated historical narrative. Combined with sibling `[Unreleased]` Security notes after rebase onto #9/#12.
172
+
173
+ ### F13. Gemspec `--fix` "many checks" — **Low** — **Remediated here**
174
+
175
+ - **Evidence:** `openvox-lint.gemspec` `spec.description` said "Includes real --fix support for many checks." Only five plugins define `#fix`.
176
+ - **Recommendation:** Name the five. Leave the "drop-in replacement for the archived puppet-lint" sentence for a later marketing pass (F7).
177
+
178
+ ---
179
+
180
+ ## 7. Dependency boundaries
181
+
182
+ ### F14. Runtime boundary is clean — **Info**
183
+
184
+ - **Evidence:** `openvox-lint.gemspec` has **no** `add_runtime_dependency`. Runtime requires: `optparse` (`cli.rb`), `json` (`report.rb`), plus core `File` / `Dir` / `Enumerable`. Dev-only: `rake`, `rspec`, `rubocop`. `Gemfile` is `gemspec` only. `Linter` / `Lexer` / `Checks` do not `require` Puppet, OpenVox, or psych/yaml.
185
+ - **Recommendation:** Keep the zero-runtime-gem boundary. A future gem-discovery loader (F8) would make RubyGems a functional dependency of check loading even if not declared — another reason honest docs were preferred.
186
+
187
+ ### F15. Packaged files omitted `docs/` — **Low** — **Remediated here**
188
+
189
+ - **Evidence:** `spec.files` listed `lib/**/*`, `bin/*`, `LICENSE`, `README.md`, `CHANGELOG.md`, `DOCUMENTATION.md`. Architecture notes would not ship.
190
+ - **Recommendation:** Include `docs/**/*`.
191
+
192
+ ---
193
+
194
+ ## Remediations in this architecture PR
195
+
196
+ | Item | Change |
197
+ |------|--------|
198
+ | F1 | Narrowed `Lexer` class comment; corrected `[1.0.0]` EPP line |
199
+ | F3 | Dropped `respond_to?(:fix_problems)` |
200
+ | F4 | Replaced formatting walk with previous-semantic-token check |
201
+ | F5 | Duplicate `new_check` names always warn |
202
+ | F7 / F9 / F11 | Dated comparison tables; 38→37; `--list-checks` copy; `git pull origin development` |
203
+ | F8 | Honest README / DOCUMENTATION / CONTRIBUTING plugin docs |
204
+ | F13 / F15 | Gemspec `--fix` wording + `docs/**/*` |
205
+ | Report | This file |
206
+
207
+ ---
208
+
209
+ ## Sibling slices (do not re-implement here)
210
+
211
+ Landed on `development` before this rebase:
212
+
213
+ | Slice | PR | Topics now on `development` |
214
+ |-------|-----|-----------------------------|
215
+ | Security | #12 | RC cannot enable `--fix` (CLI `--[no-]fix`); no-follow / symlink `File.write`; `format_github` sanitization; CSV field escaping |
216
+ | Systems | #6 | Ruby 2.5-safe ranges + expanded CI matrix |
217
+ | Systems | #7 | Fail-closed `Linter#expand_files` on missing/empty inputs |
218
+ | Systems | #9 | Release checklist: git tag + gem + GitHub Release |
219
+
220
+ Still sibling-owned / open (not in this PR):
221
+
222
+ | Topic | Notes |
223
+ |-------|-------|
224
+ | `--relative` implement-or-remove | F6/F10 — flag still stored, never read |
225
+ | Issue #3 `variable_is_lowercase` | Not in this slice |
226
+ | Issue #5 heredoc lexer raise / junk-after-end-tag | Not in this slice |
227
+ | Fail-closed unknown `-f` | `Report#format` still falls through to text |
228
+
229
+ Related drift called out above but not owned here: F6/F10 (`--relative`), fail-closed unknown `-f`.
@@ -91,18 +91,16 @@ module OpenvoxLint
91
91
  def compute_resource_indexes
92
92
  results = []; i = 0; sem = semantic_tokens
93
93
  while i < sem.length
94
- if sem[i].type == :NAME && i + 1 < sem.length && sem[i + 1].type == :LBRACE
95
- # Skip NAME { that belong to class/define/node bodies rather than
96
- # actual resources. This prevents inner statements (including other
97
- # resources) from being treated as parameters of the class itself.
98
- k = i - 1
99
- while k >= 0
100
- t = sem[k]
101
- break if t.type == :CLASS || t.type == :DEFINE || t.type == :NODE
102
- break unless t.formatting?
103
- k -= 1
104
- end
105
- if k >= 0 && [:CLASS, :DEFINE, :NODE].include?(sem[k].type)
94
+ if (sem[i].type == :NAME || sem[i].type == :CLASSREF) &&
95
+ i + 1 < sem.length && sem[i + 1].type == :LBRACE
96
+ # Skip NAME/CLASSREF { that belong to class/define/node bodies
97
+ # rather than actual resources (including resource defaults such
98
+ # as "File {"). This prevents inner statements from being treated
99
+ # as parameters of the class itself.
100
+ # `sem` is already formatting-free, so the previous semantic token
101
+ # is the only one that can be the class/define/node keyword.
102
+ prev = i.positive? ? sem[i - 1] : nil
103
+ if prev && [:CLASS, :DEFINE, :NODE].include?(prev.type)
106
104
  i += 1
107
105
  next
108
106
  end
@@ -23,7 +23,7 @@ module OpenvoxLint
23
23
  fullpath: @fullpath, ignore_comments: @ignore_comments,
24
24
  )
25
25
  @problems.concat(results)
26
- plugin.fix_problems if @configuration.fix && plugin.respond_to?(:fix_problems)
26
+ plugin.fix_problems if @configuration.fix
27
27
  end
28
28
  @problems.sort_by { |p| [p[:line] || 0, p[:column] || 0] }
29
29
  end
@@ -12,14 +12,23 @@ module OpenvoxLint
12
12
  def run
13
13
  OpenvoxLint.reset_configuration!
14
14
  @config = OpenvoxLint.configuration
15
- parse_options
15
+ # Precedence: defaults < user RC < project RC < CLI. Load RC first so
16
+ # OptionParser overrides it. --config is peeked so an explicit file is
17
+ # used during that load; --fix from RC is ignored (see Configuration).
18
+ @explicit_config_file = peek_explicit_config_file
16
19
  load_rc_file
20
+ parse_options
17
21
  if @list_checks
18
22
  list_checks; return 0
19
23
  end
20
24
  files = @args.empty? ? ['.'] : @args
21
25
  linter = Linter.new(configuration: @config)
22
- linter.run(*files)
26
+ begin
27
+ linter.run(*files)
28
+ rescue OpenvoxLint::Error => e
29
+ $stderr.puts "openvox-lint: #{e.message}"
30
+ return 1
31
+ end
23
32
  Report.new(@config).format(linter.problems)
24
33
  print_summary(linter) unless @config.log_format == 'json'
25
34
  linter.exit_code
@@ -32,16 +41,17 @@ module OpenvoxLint
32
41
  opts.banner = "Usage: openvox-lint [options] [file|directory ...]"
33
42
  opts.separator ''; opts.separator 'Options:'
34
43
  opts.on('--version', 'Display version') { puts "openvox-lint #{VERSION}"; exit 0 }
35
- opts.on('-f', '--format FORMAT', 'Output format: text json csv github codeclimate') { |f| @config.log_format = f }
44
+ opts.on('-f', '--format FORMAT', 'Output format: text json csv github codeclimate') { |f| apply_cli_format(f) }
36
45
  opts.on('--log-format FORMAT', 'Custom log format string') { |f| @config.custom_log_format = f; @config.log_format = 'custom' }
37
- opts.on('--fix', 'Automatically fix problems') { @config.fix = true }
46
+ opts.on('--[no-]fix', 'Automatically fix problems (CLI only; RC cannot enable)') { |v| @config.fix = v }
38
47
  opts.on('--fail-on-warnings', 'Exit 1 on warnings') { @config.fail_on_warnings = true }
39
48
  opts.on('--no-filename', 'Suppress filename') { @config.with_filename = false }
40
49
  opts.on('--no-column', 'Suppress column') { @config.column = false }
41
- opts.on('--relative', 'Relative paths') { @config.relative = true }
50
+ opts.on('--relative', 'Display paths relative to the current working directory') { @config.relative = true }
42
51
  opts.on('--only-checks CHECKS', 'Comma-separated checks') { |c| @config.only_checks = c.split(',').map { |s| s.strip.to_sym } }
43
52
  opts.on('--ignore-paths PATHS', 'Comma-separated globs') { |p| @config.ignore_paths = p.split(',').map(&:strip) }
44
- opts.on('--list-checks', 'List available checks') { @list_checks = true }
53
+ opts.on('--list-checks', 'List available checks (name, severity, description)') { @list_checks = true }
54
+ opts.separator ' --no-<check_name>-check Disable a check (see --list-checks)'
45
55
  opts.on('-c', '--config FILE', 'Config file path') { |f| @explicit_config_file = f }
46
56
  end
47
57
  remaining = []
@@ -59,26 +69,88 @@ module OpenvoxLint
59
69
  @args.replace(remaining)
60
70
  end
61
71
 
72
+ def peek_explicit_config_file
73
+ @args.each_with_index do |arg, idx|
74
+ if ['-c', '--config'].include?(arg)
75
+ return @args[idx + 1]
76
+ elsif arg.start_with?('--config=')
77
+ return arg.split('=', 2).last
78
+ end
79
+ end
80
+ nil
81
+ end
82
+
62
83
  def load_rc_file
63
- # Priority: explicit --config flag > local .openvox-lint.rc > ~/.openvox-lint.rc
64
- # An explicit --config flag must always win. The default value of
65
- # config_file is nil until the user passes -c / --config.
84
+ # Precedence: defaults < ~/.openvox-lint.rc (user) < .openvox-lint.rc
85
+ # (project) < CLI. An explicit --config / -c file replaces the RC chain.
66
86
  candidates = if @explicit_config_file
67
87
  [@explicit_config_file]
68
88
  else
69
- ['.openvox-lint.rc', File.expand_path('~/.openvox-lint.rc')]
89
+ [File.expand_path('~/.openvox-lint.rc'), '.openvox-lint.rc']
70
90
  end
71
91
  candidates.each do |path|
72
92
  next unless path && File.exist?(path)
73
- @config.load_from_rc(path); break
93
+ @config.load_from_rc(path)
74
94
  end
75
95
  end
76
96
 
97
+ def apply_cli_format(value)
98
+ if Configuration::NAMED_FORMATS.include?(value)
99
+ @config.log_format = value
100
+ return
101
+ end
102
+ $stderr.puts "error: invalid format '#{value}' (valid: #{Configuration::NAMED_FORMATS.join(', ')})"
103
+ exit 1
104
+ end
105
+
106
+ # CheckPlugin has no severity/description API. Read them from the plugin
107
+ # source: the kind the check passes to notify, and the file header comment.
77
108
  def list_checks
78
- puts "Available checks (#{OpenvoxLint.checks.size} total):"; puts ''
79
- OpenvoxLint.checks.keys.sort.each do |name|
80
- puts " #{@config.check_enabled?(name) ? '✓' : '✗'} #{name}"
109
+ names = OpenvoxLint.checks.keys.sort
110
+ width = [names.map { |n| n.to_s.length }.max, 8].max
111
+ puts "Available checks (#{names.size} total):"
112
+ puts ''
113
+ names.each do |name|
114
+ klass = OpenvoxLint.checks[name]
115
+ mark = @config.check_enabled?(name) ? '✓' : '✗'
116
+ severity = plugin_severity(klass)
117
+ description = plugin_description(klass)
118
+ puts format(" %s %-#{width}s %-7s %s", mark, name, severity, description).rstrip
119
+ end
120
+ end
121
+
122
+ def plugin_source_file(klass)
123
+ file, = klass.instance_method(:check).source_location
124
+ file
125
+ end
126
+
127
+ def plugin_severity(klass)
128
+ file = plugin_source_file(klass)
129
+ return 'warning' unless file && File.file?(file)
130
+ File.foreach(file) do |line|
131
+ return 'error' if line =~ /notify\s+:error\b/
132
+ return 'warning' if line =~ /notify\s+:warning\b/
133
+ end
134
+ 'warning'
135
+ end
136
+
137
+ def plugin_description(klass)
138
+ file = plugin_source_file(klass)
139
+ return '' unless file && File.file?(file)
140
+ lines = []
141
+ started = false
142
+ File.foreach(file) do |line|
143
+ stripped = line.strip
144
+ next if !started && (stripped.empty? ||
145
+ stripped == '# frozen_string_literal: true' ||
146
+ stripped.start_with?('require '))
147
+ break unless stripped.start_with?('#')
148
+ started = true
149
+ text = stripped.sub(/\A#\s?/, '')
150
+ break if text.empty? && !lines.empty?
151
+ lines << text unless text.empty?
81
152
  end
153
+ lines.join(' ')
82
154
  end
83
155
 
84
156
  def print_summary(linter)
@@ -3,6 +3,8 @@
3
3
  module OpenvoxLint
4
4
  # Holds all runtime configuration.
5
5
  class Configuration
6
+ NAMED_FORMATS = %w[text json csv github codeclimate].freeze
7
+
6
8
  DEFAULTS = {
7
9
  log_format: 'text', with_filename: true, fail_on_warnings: false,
8
10
  fix: false, only_checks: [], disabled_checks: [],
@@ -41,7 +43,10 @@ module OpenvoxLint
41
43
 
42
44
  def apply_flag(flag, value)
43
45
  case flag
44
- when '--fix' then self.fix = true
46
+ when '--fix'
47
+ # Destructive --fix must be requested on the CLI. An RC file must
48
+ # never enable writes on its own (defaults < RC < CLI).
49
+ $stderr.puts 'openvox-lint: ignoring --fix from RC file (pass --fix on the CLI to enable)'
45
50
  when '--no-fix' then self.fix = false
46
51
  when '--fail-on-warnings' then self.fail_on_warnings = true
47
52
  when /\A--no-(.+)-check\z/
@@ -49,10 +54,31 @@ module OpenvoxLint
49
54
  when '--only-checks'
50
55
  self.only_checks = (value || '').split(',').map { |c| c.strip.to_sym }
51
56
  when '--log-format'
52
- self.log_format = value&.strip || 'text'
57
+ apply_log_format_value(value)
58
+ when '--format', '-f'
59
+ apply_named_format(value)
53
60
  when '--ignore-paths'
54
61
  self.ignore_paths = (value || '').split(',').map(&:strip)
55
62
  end
56
63
  end
64
+
65
+ # --log-format accepts a named format or a custom placeholder string.
66
+ def apply_log_format_value(value)
67
+ value = value&.strip
68
+ return if value.nil? || value.empty?
69
+ if NAMED_FORMATS.include?(value)
70
+ self.log_format = value
71
+ else
72
+ self.custom_log_format = value
73
+ self.log_format = 'custom'
74
+ end
75
+ end
76
+
77
+ # -f / --format in an RC file accept named formats only (same as CLI -f).
78
+ def apply_named_format(value)
79
+ value = value&.strip
80
+ return unless NAMED_FORMATS.include?(value)
81
+ self.log_format = value
82
+ end
57
83
  end
58
84
  end
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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.
4
+ # Tokenises a Puppet / OpenVox `.pp` manifest string into an Array of
5
+ # Token objects. The lexer recognises keywords, operators, heredocs,
6
+ # regexes, interpolated strings, numbers, and type-reference names
7
+ # commonly found in Puppet 8 / OpenVox 8.x manifests. It does not
8
+ # scan EPP tags (`<%` / `%>`); `.epp` templates are not a first-class
9
+ # target. Deferred/Sensitive/type-alias syntax is tokenised as
10
+ # ordinary names and class references, not dedicated token types.
8
11
  class Lexer
9
12
  KEYWORDS = {
10
13
  'and' => :AND, 'application' => :APPLICATION, 'attr' => :ATTR,
@@ -219,6 +222,7 @@ module OpenvoxLint
219
222
  end
220
223
 
221
224
  def scan_heredoc
225
+ start_line = @line
222
226
  start_col = @column
223
227
  tag_match = @code[@pos..].match(/\A@\(("?)(\w+)\1\s*([\/\-|:tsnLru]*)\)/)
224
228
  unless tag_match
@@ -227,25 +231,38 @@ module OpenvoxLint
227
231
  tag = tag_match[2]; tag_len = tag_match[0].length
228
232
  add_token(:HEREDOC_OPEN, @code[@pos, tag_len], @line, start_col)
229
233
  @pos += tag_len; @column += tag_len
230
- # Skip rest of current line
234
+ # Skip rest of current line (e.g. a trailing comma after the open tag)
231
235
  while @pos < @code.length && @code[@pos] != "\n"
232
236
  @pos += 1; @column += 1
233
237
  end
238
+ unless @pos < @code.length && @code[@pos] == "\n"
239
+ raise OpenvoxLint::Error, "unterminated heredoc starting at line #{start_line}"
240
+ end
234
241
  @pos += 1; @line += 1; @column = 1
235
- # Read heredoc body
242
+ # Read heredoc body. An end tag must be a whole line matching
243
+ # optional strip prefix + tag + optional surrounding whitespace.
244
+ # Trailing junk (e.g. `| END,`) is not a terminator; the scan
245
+ # continues and EOF without a valid end tag is an error.
236
246
  heredoc_content = +''
247
+ terminated = false
248
+ end_tag_re = /\A[-|]?\s*#{Regexp.escape(tag)}\s*\z/
237
249
  until @pos >= @code.length
238
250
  line_start = @pos
239
251
  while @pos < @code.length && @code[@pos] != "\n"
240
252
  @pos += 1; @column += 1
241
253
  end
242
254
  current_line = @code[line_start...@pos]
243
- if current_line.strip =~ /\A[-|]?\s*#{Regexp.escape(tag)}\s*\z/
244
- @pos += 1 if @pos < @code.length; @line += 1; @column = 1; break
255
+ if current_line.strip =~ end_tag_re
256
+ @pos += 1 if @pos < @code.length; @line += 1; @column = 1
257
+ terminated = true
258
+ break
245
259
  end
246
260
  heredoc_content << current_line << "\n"
247
261
  @pos += 1; @line += 1; @column = 1
248
262
  end
263
+ if !terminated
264
+ raise OpenvoxLint::Error, "unterminated heredoc starting at line #{start_line}"
265
+ end
249
266
  add_token(:HEREDOC, heredoc_content, @line, start_col)
250
267
  end
251
268
 
@@ -51,7 +51,7 @@ module OpenvoxLint
51
51
  begin
52
52
  fixed_code = lexer.manifest_lines.join("\n") + "\n"
53
53
  if fixed_code != code
54
- File.write(filepath, fixed_code)
54
+ write_fixed_file(filepath, fixed_code)
55
55
  end
56
56
  rescue StandardError => write_err
57
57
  @problems << {
@@ -67,18 +67,69 @@ module OpenvoxLint
67
67
  }
68
68
  end
69
69
 
70
+ # Single choke point for --fix writes: never follow a symlink (leaf or
71
+ # any path component) and open with O_NOFOLLOW when the platform provides it.
72
+ def write_fixed_file(filepath, content)
73
+ if symlink_in_path?(filepath)
74
+ @problems << {
75
+ path: filepath, line: 0, column: 0, kind: :error,
76
+ check: :fix,
77
+ message: "Refusing to write fixes: #{filepath} is a symbolic link or has a symlink path component",
78
+ }
79
+ return
80
+ end
81
+
82
+ flags = File::WRONLY | File::TRUNC
83
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
84
+ File.open(filepath, flags) { |f| f.write(content) }
85
+ rescue Errno::ELOOP, Errno::EMLINK
86
+ @problems << {
87
+ path: filepath, line: 0, column: 0, kind: :error,
88
+ check: :fix,
89
+ message: "Refusing to write fixes: #{filepath} is a symbolic link or has a symlink path component",
90
+ }
91
+ end
92
+
93
+ def symlink_in_path?(filepath)
94
+ return true if File.symlink?(filepath)
95
+
96
+ # Walk each component so a symlink directory cannot redirect the write.
97
+ path = File.expand_path(filepath)
98
+ current = path.start_with?(File::SEPARATOR) ? File::SEPARATOR : nil
99
+ path.split(File::SEPARATOR).reject(&:empty?).each do |part|
100
+ current = current.nil? ? part : File.join(current, part)
101
+ return true if File.symlink?(current)
102
+ end
103
+ false
104
+ end
105
+
70
106
  def expand_files(fileargs)
107
+ if fileargs.nil? || fileargs.empty?
108
+ raise OpenvoxLint::Error, 'no files or directories specified'
109
+ end
110
+
71
111
  files = []
72
112
  fileargs.each do |arg|
73
113
  if File.directory?(arg)
74
114
  files.concat(Dir.glob(File.join(arg, '**', '*.pp')))
75
- elsif arg.include?('*')
76
- files.concat(Dir.glob(arg))
115
+ elsif arg.include?('*') || arg.include?('?') || arg.include?('[')
116
+ matches = Dir.glob(arg)
117
+ if matches.empty?
118
+ raise OpenvoxLint::Error, "glob matched no files: #{arg}"
119
+ end
120
+ files.concat(matches)
77
121
  elsif File.file?(arg)
78
122
  files << arg
123
+ else
124
+ raise OpenvoxLint::Error, "no such file or directory: #{arg}"
79
125
  end
80
126
  end
81
- files.reject { |f| ignored?(f) }.uniq
127
+
128
+ files = files.reject { |f| ignored?(f) }.uniq
129
+ if files.empty?
130
+ raise OpenvoxLint::Error, 'no Puppet manifests (.pp) found to lint'
131
+ end
132
+ files
82
133
  end
83
134
 
84
135
  def ignored?(filepath)
@@ -41,7 +41,7 @@ OpenvoxLint.new_check(:arrow_alignment) do
41
41
  def group_arrows(arrows)
42
42
  groups = []
43
43
  current = [arrows.first]
44
- arrows[1..].each do |arrow|
44
+ arrows[1..-1].each do |arrow|
45
45
  if arrow.line - current.last.line <= 2
46
46
  current << arrow
47
47
  else
@@ -17,7 +17,7 @@ OpenvoxLint.new_check(:autoloader_layout) do
17
17
  expected_path = if parts.length == 1
18
18
  "#{parts[0]}/manifests/init.pp"
19
19
  else
20
- "#{parts[0]}/manifests/#{parts[1..].join('/')}.pp"
20
+ "#{parts[0]}/manifests/#{parts[1..-1].join('/')}.pp"
21
21
  end
22
22
  next if fullpath.end_with?(expected_path)
23
23
  # Also check with just the filename portion
@@ -78,7 +78,7 @@ OpenvoxLint.new_check(:space_before_arrow) do
78
78
  groups = []
79
79
  current = [entries.first]
80
80
 
81
- entries[1..].each do |entry|
81
+ entries[1..-1].each do |entry|
82
82
  if entry[:arrow].line - current.last[:arrow].line <= 2
83
83
  current << entry
84
84
  else