yaml-janitor 20251113
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +185 -0
- data/bin/yaml-janitor +132 -0
- data/lib/yaml_janitor/config.rb +86 -0
- data/lib/yaml_janitor/linter.rb +108 -0
- data/lib/yaml_janitor/rule.rb +73 -0
- data/lib/yaml_janitor/rules/consistent_indentation.rb +70 -0
- data/lib/yaml_janitor/rules/multiline_certificate.rb +58 -0
- data/lib/yaml_janitor/version.rb +5 -0
- data/lib/yaml_janitor/violation.rb +25 -0
- data/lib/yaml_janitor.rb +32 -0
- metadata +98 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 022b412fb7fefdf3b91aae1bb4d47db513c144e1ddb3e99b6d0b83ee9141ac90
|
|
4
|
+
data.tar.gz: 0d0f1fd010e75a0bceda041960f03432b283091fb8425b2909d3f0f4beaadc80
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 18655c70a33f9db707541e76d6951bbb98b3f227028f933101ff8dd0ea28d2b0fa319a36af467ccb8b52ea7bb46727ef95d8e6625a5b1d647ebdda6ee4601958
|
|
7
|
+
data.tar.gz: d48f8f842813eb5538c4e939969736b74b1fc8c2414f7be91d9890108d57336015276c6802b35ee138414e88175305c43cf2b85732e9c8ce95873c893083ad1b
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 ducks
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# yaml-janitor
|
|
2
|
+
|
|
3
|
+
A YAML linter built on psych-pure that preserves comments while detecting and
|
|
4
|
+
fixing issues.
|
|
5
|
+
|
|
6
|
+
## Why?
|
|
7
|
+
|
|
8
|
+
Traditional YAML tools destroy comments when editing files. yaml-janitor uses
|
|
9
|
+
psych-pure's comment-preserving parser to lint and fix YAML files without
|
|
10
|
+
losing valuable documentation.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
gem install yaml-janitor
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or in your Gemfile:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem 'yaml-janitor'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
### CLI
|
|
27
|
+
|
|
28
|
+
Check a single file:
|
|
29
|
+
```bash
|
|
30
|
+
yaml-janitor config.yml
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Check all YAML files in a directory:
|
|
34
|
+
```bash
|
|
35
|
+
yaml-janitor containers/
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Auto-fix issues:
|
|
39
|
+
```bash
|
|
40
|
+
yaml-janitor --fix config.yml
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Run specific rules:
|
|
44
|
+
```bash
|
|
45
|
+
yaml-janitor --rules multiline_certificate config.yml
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Ruby API
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
require 'yaml_janitor'
|
|
52
|
+
|
|
53
|
+
# Lint a file
|
|
54
|
+
result = YamlJanitor.lint_file("config.yml")
|
|
55
|
+
result[:violations].each do |violation|
|
|
56
|
+
puts violation
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Lint and fix
|
|
60
|
+
result = YamlJanitor.lint_file("config.yml", fix: true)
|
|
61
|
+
if result[:fixed]
|
|
62
|
+
puts "Fixed! New content:\n#{result[:output]}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Lint a string
|
|
66
|
+
yaml_string = File.read("config.yml")
|
|
67
|
+
result = YamlJanitor.lint(yaml_string)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Configuration
|
|
71
|
+
|
|
72
|
+
Create a `.yaml-janitor.yml` file in your project root:
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
# Formatting options (applied during --fix)
|
|
76
|
+
indentation: 2
|
|
77
|
+
line_width: 80
|
|
78
|
+
sequence_indent: false
|
|
79
|
+
|
|
80
|
+
# Rule configuration
|
|
81
|
+
rules:
|
|
82
|
+
multiline_certificate:
|
|
83
|
+
enabled: true
|
|
84
|
+
consistent_indentation:
|
|
85
|
+
enabled: true
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Configuration Options
|
|
89
|
+
|
|
90
|
+
**Formatting**:
|
|
91
|
+
- `indentation`: Number of spaces for indentation (default: 2)
|
|
92
|
+
- `line_width`: Maximum line width before wrapping (default: 80)
|
|
93
|
+
- `sequence_indent`: Indent sequences under their key (default: false)
|
|
94
|
+
|
|
95
|
+
**Rules**:
|
|
96
|
+
- `multiline_certificate`: Detects multi-line certificates in double-quoted strings
|
|
97
|
+
- `consistent_indentation`: Detects and fixes inconsistent indentation
|
|
98
|
+
|
|
99
|
+
### Command Line Overrides
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# Override config file settings
|
|
103
|
+
yaml-janitor --indentation 4 --line-width 100 config.yml
|
|
104
|
+
|
|
105
|
+
# Use a specific config file
|
|
106
|
+
yaml-janitor --config production.yml containers/
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Rules
|
|
110
|
+
|
|
111
|
+
### multiline_certificate
|
|
112
|
+
|
|
113
|
+
Detects multi-line certificates embedded in double-quoted strings. This pattern
|
|
114
|
+
triggers a psych-pure parser bug.
|
|
115
|
+
|
|
116
|
+
```yaml
|
|
117
|
+
# BAD (will trigger violation)
|
|
118
|
+
DISCOURSE_SAML_CERT: "-----BEGIN CERTIFICATE-----
|
|
119
|
+
MIIDGDCCAgCgAwIBAgIVAMP/9hm9Vl3/23QoXrL8hQ31DLwRMA0GCSqGSIb3DQEB
|
|
120
|
+
-----END CERTIFICATE-----"
|
|
121
|
+
|
|
122
|
+
# GOOD (use block literal style)
|
|
123
|
+
DISCOURSE_SAML_CERT: |
|
|
124
|
+
-----BEGIN CERTIFICATE-----
|
|
125
|
+
MIIDGDCCAgCgAwIBAgIVAMP/9hm9Vl3/23QoXrL8hQ31DLwRMA0GCSqGSIb3DQEB
|
|
126
|
+
-----END CERTIFICATE-----
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Auto-fix**: Not yet implemented (requires psych-pure enhancements)
|
|
130
|
+
|
|
131
|
+
### consistent_indentation
|
|
132
|
+
|
|
133
|
+
Detects inconsistent indentation (mixing 2-space, 4-space, etc.) in YAML files.
|
|
134
|
+
|
|
135
|
+
```yaml
|
|
136
|
+
# BAD (inconsistent: 4 and 8 spaces)
|
|
137
|
+
database:
|
|
138
|
+
host: "localhost"
|
|
139
|
+
config:
|
|
140
|
+
timeout: 30
|
|
141
|
+
|
|
142
|
+
# GOOD (consistent: 2 spaces)
|
|
143
|
+
database:
|
|
144
|
+
host: "localhost"
|
|
145
|
+
config:
|
|
146
|
+
timeout: 30
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Auto-fix**: Yes, normalizes to configured indentation (default: 2 spaces)
|
|
150
|
+
|
|
151
|
+
## Development
|
|
152
|
+
|
|
153
|
+
### Running Tests
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
# Run integration tests
|
|
157
|
+
ruby -I lib test/integration_test.rb
|
|
158
|
+
|
|
159
|
+
# Or with rake (if configured)
|
|
160
|
+
bundle install
|
|
161
|
+
bundle exec rake test
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Test Coverage
|
|
165
|
+
|
|
166
|
+
Integration tests verify:
|
|
167
|
+
- Comment preservation during fixes
|
|
168
|
+
- Indentation normalization
|
|
169
|
+
- Paranoid mode (semantic verification)
|
|
170
|
+
- Config loading and rule enable/disable
|
|
171
|
+
- Multi-line certificate detection
|
|
172
|
+
- Clean files pass without violations
|
|
173
|
+
|
|
174
|
+
## Background
|
|
175
|
+
|
|
176
|
+
This tool was built to support YAML comment preservation in Discourse ops
|
|
177
|
+
automation. See the original discussion:
|
|
178
|
+
https://dev.discourse.org/t/should-we-lint-our-yaml-files/33593
|
|
179
|
+
|
|
180
|
+
Built on top of Kevin Newton's psych-pure gem, which provides a pure Ruby YAML
|
|
181
|
+
parser with comment preservation.
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT
|
data/bin/yaml-janitor
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../lib/yaml_janitor"
|
|
5
|
+
|
|
6
|
+
def print_usage
|
|
7
|
+
puts <<~USAGE
|
|
8
|
+
Usage: yaml-janitor [options] <file_or_directory>
|
|
9
|
+
|
|
10
|
+
Options:
|
|
11
|
+
--fix Auto-fix issues where possible
|
|
12
|
+
--rules RULES Comma-separated list of rules (default: all)
|
|
13
|
+
--config PATH Path to config file (default: .yaml-janitor.yml)
|
|
14
|
+
--indentation N Number of spaces for indentation (default: 2)
|
|
15
|
+
--line-width N Maximum line width (default: 80)
|
|
16
|
+
--help Show this help message
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
yaml-janitor config.yml
|
|
20
|
+
yaml-janitor --fix config.yml
|
|
21
|
+
yaml-janitor --rules multiline_certificate containers/
|
|
22
|
+
yaml-janitor --config my-config.yml --fix config.yml
|
|
23
|
+
yaml-janitor --indentation 4 --line-width 100 config.yml
|
|
24
|
+
USAGE
|
|
25
|
+
exit 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Parse args
|
|
29
|
+
fix = false
|
|
30
|
+
rules = :all
|
|
31
|
+
config_path = nil
|
|
32
|
+
config_overrides = {}
|
|
33
|
+
paths = []
|
|
34
|
+
|
|
35
|
+
i = 0
|
|
36
|
+
while i < ARGV.length
|
|
37
|
+
case ARGV[i]
|
|
38
|
+
when "--fix"
|
|
39
|
+
fix = true
|
|
40
|
+
when "--rules"
|
|
41
|
+
i += 1
|
|
42
|
+
rules = ARGV[i].split(",").map(&:to_sym)
|
|
43
|
+
when "--config"
|
|
44
|
+
i += 1
|
|
45
|
+
config_path = ARGV[i]
|
|
46
|
+
when "--indentation"
|
|
47
|
+
i += 1
|
|
48
|
+
config_overrides[:indentation] = ARGV[i].to_i
|
|
49
|
+
when "--line-width"
|
|
50
|
+
i += 1
|
|
51
|
+
config_overrides[:line_width] = ARGV[i].to_i
|
|
52
|
+
when "--help", "-h"
|
|
53
|
+
print_usage
|
|
54
|
+
else
|
|
55
|
+
paths << ARGV[i]
|
|
56
|
+
end
|
|
57
|
+
i += 1
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if paths.empty?
|
|
61
|
+
puts "Error: No files or directories specified"
|
|
62
|
+
print_usage
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Look for default config if not specified
|
|
66
|
+
if !config_path && File.exist?(".yaml-janitor.yml")
|
|
67
|
+
config_path = ".yaml-janitor.yml"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Process files
|
|
71
|
+
config = YamlJanitor::Config.new(config_path: config_path, overrides: config_overrides)
|
|
72
|
+
linter = YamlJanitor::Linter.new(rules: rules, config: config)
|
|
73
|
+
total_files = 0
|
|
74
|
+
total_violations = 0
|
|
75
|
+
files_with_violations = []
|
|
76
|
+
|
|
77
|
+
paths.each do |path|
|
|
78
|
+
if File.directory?(path)
|
|
79
|
+
# Process all YAML files in directory
|
|
80
|
+
yaml_files = Dir.glob(File.join(path, "**", "*.{yml,yaml}"))
|
|
81
|
+
yaml_files.each do |file|
|
|
82
|
+
total_files += 1
|
|
83
|
+
result = linter.lint_file(file, fix: fix)
|
|
84
|
+
|
|
85
|
+
if result[:violations].any?
|
|
86
|
+
files_with_violations << file
|
|
87
|
+
total_violations += result[:violations].length
|
|
88
|
+
|
|
89
|
+
puts "\n#{file}:"
|
|
90
|
+
result[:violations].each do |violation|
|
|
91
|
+
puts " #{violation}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
if fix && result[:fixed]
|
|
95
|
+
puts " ✓ Fixed"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
elsif File.file?(path)
|
|
100
|
+
# Process single file
|
|
101
|
+
total_files += 1
|
|
102
|
+
result = linter.lint_file(path, fix: fix)
|
|
103
|
+
|
|
104
|
+
if result[:violations].any?
|
|
105
|
+
files_with_violations << path
|
|
106
|
+
total_violations += result[:violations].length
|
|
107
|
+
|
|
108
|
+
puts "\n#{path}:"
|
|
109
|
+
result[:violations].each do |violation|
|
|
110
|
+
puts " #{violation}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
if fix && result[:fixed]
|
|
114
|
+
puts " ✓ Fixed"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
else
|
|
118
|
+
puts "Warning: #{path} not found"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Summary
|
|
123
|
+
puts "\n" + "="*60
|
|
124
|
+
puts "Checked #{total_files} files"
|
|
125
|
+
puts "Found #{total_violations} violations in #{files_with_violations.length} files"
|
|
126
|
+
|
|
127
|
+
if total_violations > 0
|
|
128
|
+
exit 1
|
|
129
|
+
else
|
|
130
|
+
puts "✓ All files clean!"
|
|
131
|
+
exit 0
|
|
132
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module YamlJanitor
|
|
6
|
+
class Config
|
|
7
|
+
DEFAULT_CONFIG = {
|
|
8
|
+
indentation: 2,
|
|
9
|
+
line_width: 80,
|
|
10
|
+
sequence_indent: false,
|
|
11
|
+
rules: {
|
|
12
|
+
multiline_certificate: { enabled: true },
|
|
13
|
+
consistent_indentation: { enabled: true }
|
|
14
|
+
}
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :config
|
|
18
|
+
|
|
19
|
+
def initialize(config_path: nil, overrides: {})
|
|
20
|
+
@config = deep_dup(DEFAULT_CONFIG)
|
|
21
|
+
load_config_file(config_path) if config_path
|
|
22
|
+
merge_overrides(overrides)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def indentation
|
|
26
|
+
@config[:indentation]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def line_width
|
|
30
|
+
@config[:line_width]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def sequence_indent
|
|
34
|
+
@config[:sequence_indent]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def rule_enabled?(rule_name)
|
|
38
|
+
rule_config = @config[:rules][rule_name.to_sym]
|
|
39
|
+
rule_config && rule_config[:enabled] != false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def rule_config(rule_name)
|
|
43
|
+
@config[:rules][rule_name.to_sym] || {}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def dump_options
|
|
47
|
+
{
|
|
48
|
+
indentation: indentation,
|
|
49
|
+
line_width: line_width,
|
|
50
|
+
sequence_indent: sequence_indent
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def load_config_file(path)
|
|
57
|
+
return unless File.exist?(path)
|
|
58
|
+
|
|
59
|
+
file_config = YAML.safe_load(File.read(path), symbolize_names: true)
|
|
60
|
+
deep_merge!(@config, file_config)
|
|
61
|
+
rescue => e
|
|
62
|
+
warn "Warning: Could not load config file #{path}: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def merge_overrides(overrides)
|
|
66
|
+
deep_merge!(@config, overrides)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def deep_dup(hash)
|
|
70
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
71
|
+
result[key] = value.is_a?(Hash) ? deep_dup(value) : value
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def deep_merge!(hash, other_hash)
|
|
76
|
+
other_hash.each do |key, value|
|
|
77
|
+
if value.is_a?(Hash) && hash[key].is_a?(Hash)
|
|
78
|
+
deep_merge!(hash[key], value)
|
|
79
|
+
else
|
|
80
|
+
hash[key] = value
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
hash
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YamlJanitor
|
|
4
|
+
class Linter
|
|
5
|
+
attr_reader :rules
|
|
6
|
+
|
|
7
|
+
def initialize(rules: :all, config: nil, config_path: nil)
|
|
8
|
+
@config = config || Config.new(config_path: config_path)
|
|
9
|
+
@rules = load_rules(rules)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Lint a file, optionally fixing issues
|
|
13
|
+
def lint_file(path, fix: false)
|
|
14
|
+
yaml_content = File.read(path)
|
|
15
|
+
result = lint(yaml_content, fix: fix, file: path)
|
|
16
|
+
|
|
17
|
+
if fix && result[:fixed]
|
|
18
|
+
File.write(path, result[:output])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
result
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Lint YAML content, optionally fixing issues
|
|
25
|
+
def lint(yaml_content, fix: false, file: nil)
|
|
26
|
+
violations = []
|
|
27
|
+
|
|
28
|
+
# Load with comments
|
|
29
|
+
loaded = Psych::Pure.load(yaml_content, comments: true)
|
|
30
|
+
|
|
31
|
+
# Check for violations
|
|
32
|
+
@rules.each do |rule|
|
|
33
|
+
violations += rule.check(loaded, file: file)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Apply fixes if requested
|
|
37
|
+
output = yaml_content
|
|
38
|
+
fixed = false
|
|
39
|
+
|
|
40
|
+
if fix && violations.any?
|
|
41
|
+
@rules.each do |rule|
|
|
42
|
+
rule.fix!(loaded)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Dump back to YAML with configured options
|
|
46
|
+
output = Psych::Pure.dump(loaded, **@config.dump_options)
|
|
47
|
+
fixed = true
|
|
48
|
+
|
|
49
|
+
# Paranoid mode: verify semantics match
|
|
50
|
+
verify_semantics!(yaml_content, output)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
violations: violations,
|
|
55
|
+
fixed: fixed,
|
|
56
|
+
output: output
|
|
57
|
+
}
|
|
58
|
+
rescue => e
|
|
59
|
+
{
|
|
60
|
+
violations: [Violation.new(
|
|
61
|
+
rule: :parse_error,
|
|
62
|
+
message: e.message,
|
|
63
|
+
file: file
|
|
64
|
+
)],
|
|
65
|
+
fixed: false,
|
|
66
|
+
output: yaml_content,
|
|
67
|
+
error: e
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def load_rules(rule_specs)
|
|
74
|
+
available_rules = {
|
|
75
|
+
multiline_certificate: Rules::MultilineCertificate,
|
|
76
|
+
consistent_indentation: Rules::ConsistentIndentation
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if rule_specs == :all
|
|
80
|
+
# Load all enabled rules from config
|
|
81
|
+
rule_names = available_rules.keys.select do |name|
|
|
82
|
+
@config.rule_enabled?(name)
|
|
83
|
+
end
|
|
84
|
+
elsif rule_specs.is_a?(Array)
|
|
85
|
+
rule_names = rule_specs
|
|
86
|
+
else
|
|
87
|
+
raise Error, "Invalid rules specification: #{rule_specs.inspect}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
rule_names.map do |name|
|
|
91
|
+
rule_class = available_rules[name.to_sym]
|
|
92
|
+
raise Error, "Unknown rule: #{name}" unless rule_class
|
|
93
|
+
next unless @config.rule_enabled?(name)
|
|
94
|
+
|
|
95
|
+
rule_class.new(@config.rule_config(name))
|
|
96
|
+
end.compact
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def verify_semantics!(original, fixed)
|
|
100
|
+
original_data = YAML.load(original)
|
|
101
|
+
fixed_data = YAML.load(fixed)
|
|
102
|
+
|
|
103
|
+
if original_data != fixed_data
|
|
104
|
+
raise SemanticMismatchError, "Fixed YAML has different semantics than original"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YamlJanitor
|
|
4
|
+
class Rule
|
|
5
|
+
def initialize(config = {})
|
|
6
|
+
@config = config
|
|
7
|
+
end
|
|
8
|
+
# Check for violations in the loaded YAML structure
|
|
9
|
+
# Returns an array of Violation objects
|
|
10
|
+
def check(loaded, file: nil)
|
|
11
|
+
violations = []
|
|
12
|
+
walk(loaded) do |node, path|
|
|
13
|
+
if violation = check_node(node, path)
|
|
14
|
+
violations << Violation.new(
|
|
15
|
+
rule: rule_name,
|
|
16
|
+
message: violation,
|
|
17
|
+
file: file
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
violations
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Fix violations in the loaded YAML structure
|
|
25
|
+
# Modifies the structure in place
|
|
26
|
+
def fix!(loaded)
|
|
27
|
+
walk(loaded) do |node, path|
|
|
28
|
+
fix_node(node, path)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Override this to check individual nodes
|
|
33
|
+
def check_node(node, path)
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Override this to fix individual nodes
|
|
38
|
+
def fix_node(node, path)
|
|
39
|
+
# No-op by default
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Override this to provide the rule name
|
|
43
|
+
def rule_name
|
|
44
|
+
self.class.name.split("::").last.downcase
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Walk the YAML structure, yielding each node with its path
|
|
50
|
+
def walk(node, path = [], &block)
|
|
51
|
+
yield node, path
|
|
52
|
+
|
|
53
|
+
# Handle both regular and LoadedHash/LoadedArray from psych-pure
|
|
54
|
+
if hash_like?(node)
|
|
55
|
+
node.each do |key, value|
|
|
56
|
+
walk(value, path + [key], &block)
|
|
57
|
+
end
|
|
58
|
+
elsif array_like?(node)
|
|
59
|
+
node.each_with_index do |value, index|
|
|
60
|
+
walk(value, path + [index], &block)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def hash_like?(node)
|
|
66
|
+
node.is_a?(Hash) || node.class.name == 'Psych::Pure::LoadedHash'
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def array_like?(node)
|
|
70
|
+
node.is_a?(Array) || node.class.name == 'Psych::Pure::LoadedArray'
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YamlJanitor
|
|
4
|
+
module Rules
|
|
5
|
+
class ConsistentIndentation < Rule
|
|
6
|
+
def rule_name
|
|
7
|
+
"consistent_indentation"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# This rule checks the original YAML source for inconsistent indentation
|
|
11
|
+
# The fix is automatic via Psych::Pure.dump with configured indentation
|
|
12
|
+
def check(loaded, file: nil)
|
|
13
|
+
return [] unless file
|
|
14
|
+
|
|
15
|
+
# Read the original source
|
|
16
|
+
source = File.read(file)
|
|
17
|
+
|
|
18
|
+
violations = []
|
|
19
|
+
indentation_levels = detect_indentation_levels(source)
|
|
20
|
+
|
|
21
|
+
if indentation_levels.length > 1
|
|
22
|
+
violations << Violation.new(
|
|
23
|
+
rule: rule_name,
|
|
24
|
+
message: "Inconsistent indentation detected: #{indentation_levels.sort.join(', ')} spaces used",
|
|
25
|
+
file: file
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
violations
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Fix is automatic - Psych::Pure.dump will use configured indentation
|
|
33
|
+
def fix!(loaded)
|
|
34
|
+
# No-op - the dumper handles this via config.dump_options
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def detect_indentation_levels(source)
|
|
40
|
+
# Track the indentation increment between parent and child
|
|
41
|
+
indents = []
|
|
42
|
+
prev_indent = 0
|
|
43
|
+
|
|
44
|
+
source.each_line do |line|
|
|
45
|
+
# Skip empty lines, comments, and document markers
|
|
46
|
+
next if line.strip.empty?
|
|
47
|
+
next if line.strip.start_with?('#')
|
|
48
|
+
next if line.strip.start_with?('---')
|
|
49
|
+
next if line.strip.start_with?('...')
|
|
50
|
+
next unless line.include?(':') # Only look at key lines
|
|
51
|
+
|
|
52
|
+
# Count leading spaces
|
|
53
|
+
spaces = 0
|
|
54
|
+
spaces = line[/^ +/].length if line.start_with?(' ')
|
|
55
|
+
|
|
56
|
+
# Calculate the indent increment from previous level
|
|
57
|
+
if spaces > prev_indent
|
|
58
|
+
indent_increment = spaces - prev_indent
|
|
59
|
+
indents << indent_increment
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
prev_indent = spaces if line.strip.end_with?(':')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Find unique indentation increments
|
|
66
|
+
indents.uniq
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YamlJanitor
|
|
4
|
+
module Rules
|
|
5
|
+
class MultilineCertificate < Rule
|
|
6
|
+
CERT_PATTERNS = [
|
|
7
|
+
/BEGIN CERTIFICATE/,
|
|
8
|
+
/BEGIN RSA PRIVATE KEY/,
|
|
9
|
+
/BEGIN PRIVATE KEY/,
|
|
10
|
+
/BEGIN PUBLIC KEY/
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
def rule_name
|
|
14
|
+
"multiline_certificate"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def check_node(node, path)
|
|
18
|
+
return unless node.is_a?(String)
|
|
19
|
+
return unless contains_certificate?(node)
|
|
20
|
+
return unless has_embedded_newlines?(node)
|
|
21
|
+
|
|
22
|
+
key = path.last
|
|
23
|
+
"Key '#{key}' contains a multi-line certificate in double-quoted format (causes psych-pure bug)"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def fix_node(node, path)
|
|
27
|
+
return unless node.is_a?(String)
|
|
28
|
+
return unless contains_certificate?(node)
|
|
29
|
+
return unless has_embedded_newlines?(node)
|
|
30
|
+
|
|
31
|
+
# For LoadedObject (psych-pure's wrapper), we need to modify the underlying string
|
|
32
|
+
if node.respond_to?(:__getobj__)
|
|
33
|
+
underlying = node.__getobj__
|
|
34
|
+
# Mark as dirty if it's a LoadedObject
|
|
35
|
+
node.instance_variable_set(:@dirty, true) if node.respond_to?(:instance_variable_set)
|
|
36
|
+
else
|
|
37
|
+
underlying = node
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Can't actually fix this yet - psych-pure doesn't support setting style hints on strings
|
|
41
|
+
# This would need psych-pure to support something like:
|
|
42
|
+
# node.psych_style = :literal
|
|
43
|
+
#
|
|
44
|
+
# For now, just detect the issue
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def contains_certificate?(string)
|
|
50
|
+
CERT_PATTERNS.any? { |pattern| string.match?(pattern) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def has_embedded_newlines?(string)
|
|
54
|
+
string.include?("\n")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YamlJanitor
|
|
4
|
+
class Violation
|
|
5
|
+
attr_reader :rule, :message, :line, :column, :file
|
|
6
|
+
|
|
7
|
+
def initialize(rule:, message:, line: nil, column: nil, file: nil)
|
|
8
|
+
@rule = rule
|
|
9
|
+
@message = message
|
|
10
|
+
@line = line
|
|
11
|
+
@column = column
|
|
12
|
+
@file = file
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_s
|
|
16
|
+
location = []
|
|
17
|
+
location << file if file
|
|
18
|
+
location << "line #{line}" if line
|
|
19
|
+
location << "column #{column}" if column
|
|
20
|
+
|
|
21
|
+
prefix = location.empty? ? "" : "#{location.join(":")} - "
|
|
22
|
+
"#{prefix}[#{rule}] #{message}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/yaml_janitor.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "psych/pure"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
require_relative "yaml_janitor/version"
|
|
7
|
+
require_relative "yaml_janitor/config"
|
|
8
|
+
require_relative "yaml_janitor/linter"
|
|
9
|
+
require_relative "yaml_janitor/rule"
|
|
10
|
+
require_relative "yaml_janitor/violation"
|
|
11
|
+
require_relative "yaml_janitor/rules/multiline_certificate"
|
|
12
|
+
require_relative "yaml_janitor/rules/consistent_indentation"
|
|
13
|
+
|
|
14
|
+
module YamlJanitor
|
|
15
|
+
class Error < StandardError; end
|
|
16
|
+
|
|
17
|
+
class SemanticMismatchError < Error; end
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Convenience method to lint a file
|
|
21
|
+
def lint_file(path, rules: :all, fix: false)
|
|
22
|
+
linter = Linter.new(rules: rules)
|
|
23
|
+
linter.lint_file(path, fix: fix)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Convenience method to lint a string
|
|
27
|
+
def lint(yaml_string, rules: :all, fix: false)
|
|
28
|
+
linter = Linter.new(rules: rules)
|
|
29
|
+
linter.lint(yaml_string, fix: fix)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: yaml-janitor
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: '20251113'
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ducks
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2025-11-13 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: psych-pure
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0.2'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0.2'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: minitest
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '5.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '5.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '13.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '13.0'
|
|
55
|
+
description: A YAML linter built on psych-pure that can detect and fix common issues
|
|
56
|
+
while preserving comments
|
|
57
|
+
email:
|
|
58
|
+
- ducks@discourse.org
|
|
59
|
+
executables:
|
|
60
|
+
- yaml-janitor
|
|
61
|
+
extensions: []
|
|
62
|
+
extra_rdoc_files: []
|
|
63
|
+
files:
|
|
64
|
+
- LICENSE
|
|
65
|
+
- README.md
|
|
66
|
+
- bin/yaml-janitor
|
|
67
|
+
- lib/yaml_janitor.rb
|
|
68
|
+
- lib/yaml_janitor/config.rb
|
|
69
|
+
- lib/yaml_janitor/linter.rb
|
|
70
|
+
- lib/yaml_janitor/rule.rb
|
|
71
|
+
- lib/yaml_janitor/rules/consistent_indentation.rb
|
|
72
|
+
- lib/yaml_janitor/rules/multiline_certificate.rb
|
|
73
|
+
- lib/yaml_janitor/version.rb
|
|
74
|
+
- lib/yaml_janitor/violation.rb
|
|
75
|
+
homepage: https://github.com/ducks/yaml-janitor
|
|
76
|
+
licenses:
|
|
77
|
+
- MIT
|
|
78
|
+
metadata: {}
|
|
79
|
+
post_install_message:
|
|
80
|
+
rdoc_options: []
|
|
81
|
+
require_paths:
|
|
82
|
+
- lib
|
|
83
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: 3.2.0
|
|
88
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '0'
|
|
93
|
+
requirements: []
|
|
94
|
+
rubygems_version: 3.5.22
|
|
95
|
+
signing_key:
|
|
96
|
+
specification_version: 4
|
|
97
|
+
summary: YAML linter that preserves comments using psych-pure
|
|
98
|
+
test_files: []
|