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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -1
- data/DOCUMENTATION.md +77 -27
- data/README.md +76 -27
- data/docs/ARCHITECTURE_REVIEW.md +229 -0
- data/lib/openvox-lint/check_plugin.rb +10 -12
- data/lib/openvox-lint/checks.rb +1 -1
- data/lib/openvox-lint/cli.rb +86 -14
- data/lib/openvox-lint/configuration.rb +28 -2
- data/lib/openvox-lint/lexer.rb +25 -8
- data/lib/openvox-lint/linter.rb +55 -4
- data/lib/openvox-lint/plugins/checks/arrow_alignment.rb +1 -1
- data/lib/openvox-lint/plugins/checks/autoloader_layout.rb +1 -1
- data/lib/openvox-lint/plugins/checks/space_before_arrow.rb +1 -1
- data/lib/openvox-lint/plugins/checks/trailing_comma.rb +1 -1
- data/lib/openvox-lint/plugins/checks/variable_is_lowercase.rb +1 -1
- data/lib/openvox-lint/report.rb +51 -3
- data/lib/openvox-lint/version.rb +1 -1
- data/lib/openvox-lint.rb +1 -1
- metadata +9 -6
|
@@ -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
|
|
95
|
-
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
data/lib/openvox-lint/checks.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/openvox-lint/cli.rb
CHANGED
|
@@ -12,14 +12,23 @@ module OpenvoxLint
|
|
|
12
12
|
def run
|
|
13
13
|
OpenvoxLint.reset_configuration!
|
|
14
14
|
@config = OpenvoxLint.configuration
|
|
15
|
-
|
|
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
|
-
|
|
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|
|
|
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 =
|
|
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', '
|
|
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
|
-
#
|
|
64
|
-
# An explicit --config
|
|
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
|
-
['
|
|
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)
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
data/lib/openvox-lint/lexer.rb
CHANGED
|
@@ -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
|
|
5
|
-
# objects. The lexer recognises
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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 =~
|
|
244
|
-
@pos += 1 if @pos < @code.length; @line += 1; @column = 1
|
|
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
|
|
data/lib/openvox-lint/linter.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
|
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
|