A trailing comma in JSON or a tab in YAML takes a service down at 2 a.m. This Ruby linter walks a directory, validates syntax, flags duplicate keys and world-writable files, and enforces required keys, with CI-friendly exit codes.
Step through the build below:
Config errors are the cheapest outages to prevent and the most embarrassing to cause. Every format has its own failure: JSON rejects trailing commas, YAML forbids tab indentation, INI silently lets a duplicate key override the first. This script runs them all in one pass so it can sit in a pre-commit hook or CI stage.
#!/usr/bin/env ruby
# frozen_string_literal: true
# config_lint.rb - lint a directory of JSON / YAML / INI-style config files before deploy.
# Usage: ruby config_lint.rb DIR [--require-keys db.host,db.port] [--json]
require 'json'
require 'yaml'
require 'optparse'
Issue = Struct.new(:file, :severity, :message)
def dig_path(obj, path)
path.split('.').reduce(obj) { |o, k| o.is_a?(Hash) ? o[k] : (return nil) }
end
def lint_text(path, text, issues)
issues << Issue.new(path, :low, 'no trailing newline') unless text.end_with?("\n") || text.empty?
issues << Issue.new(path, :low, 'mixed CRLF and LF line endings') if text.include?("\r\n") && text.gsub("\r\n", '').include?("\n")
text.each_line.with_index(1) do |l, n|
issues << Issue.new(path, :low, "line #{n}: trailing whitespace") if l.chomp =~ /[ \t]+$/
end
end
def lint_json(path, text, issues, required)
data = JSON.parse(text)
required.each { |k| issues << Issue.new(path, :high, "missing required key #{k}") if dig_path(data, k).nil? }
rescue JSON::ParserError => e
issues << Issue.new(path, :high, "invalid JSON: #{e.message.lines.first.strip[0, 90]}")
end
def lint_yaml(path, text, issues, required)
text.each_line.with_index(1) do |l, n|
issues << Issue.new(path, :high, "line #{n}: tab character used for indentation") if l =~ /\A\t/
end
data = YAML.safe_load(text, aliases: true)
required.each { |k| issues << Issue.new(path, :high, "missing required key #{k}") if dig_path(data, k).nil? } if data.is_a?(Hash)
rescue Psych::Exception => e
issues << Issue.new(path, :high, "invalid YAML: #{e.message[0, 90]}")
end
def lint_ini(path, text, issues)
seen = Hash.new { |h, k| h[k] = {} }
section = '(global)'
text.each_line.with_index(1) do |l, n|
s = l.strip
next if s.empty? || s.start_with?('#', ';')
if s =~ /\A\[(.+)\]\z/ then section = Regexp.last_match(1)
elsif s.include?('=')
key = s.split('=', 2).first.strip
issues << Issue.new(path, :medium, "line #{n}: duplicate key '#{key}' in [#{section}] (first on line #{seen[section][key]})") if seen[section].key?(key)
seen[section][key] ||= n
else
issues << Issue.new(path, :high, "line #{n}: not a section, comment or key=value")
end
end
end
opts = { required: [], json: false }
OptionParser.new do |o|
o.on('--require-keys LIST') { |v| opts[:required] = v.split(',') }
o.on('--json') { opts[:json] = true }
end.parse!
dir = ARGV.shift or abort 'usage: config_lint.rb DIR'
issues = []
files = Dir.glob(File.join(dir, '**', '*.{json,yml,yaml,ini,conf,cfg}')).sort
files.each do |f|
text = File.read(f)
issues << Issue.new(f, :medium, format('world-writable (mode %o)', File.stat(f).mode & 0o777)) if File.stat(f).mode & 0o002 != 0
lint_text(f, text, issues)
case File.extname(f)
when '.json' then lint_json(f, text, issues, opts[:required])
when '.yml', '.yaml' then lint_yaml(f, text, issues, opts[:required])
else lint_ini(f, text, issues)
end
end
if opts[:json]
puts JSON.pretty_generate(files: files.size, issues: issues.map(&:to_h))
else
puts "Checked #{files.size} file(s)"
issues.each { |i| puts "[#{i.severity.to_s.upcase}] #{i.file}: #{i.message}" }
puts 'OK: all configs clean' if issues.empty?
end
exit(issues.any? { |i| i.severity == :high } ? 2 : (issues.empty? ? 0 : 1))
1. Find files. Dir.glob with a brace pattern collects .json .yml .yaml .ini .conf .cfg recursively and sorts them for stable output.
2. Format-independent checks. Trailing newline, mixed CRLF/LF, trailing whitespace per line, and world-writable permission bits via File.stat(f).mode & 0o002.
3. Format-specific checks. JSON and YAML are parsed (YAML with safe_load, so nothing executes); parse errors become high-severity issues. YAML tab indentation is caught before parsing for a clearer message. INI is scanned line by line to find duplicate keys per section and lines that are neither comments, sections nor key=value.
4. Required keys. --require-keys db.host,db.port walks dotted paths into the parsed data and reports any that are missing. Exit code 2 on any high finding makes CI fail.
$ captured from a real test run
$ ruby config_lint.rb conf/ --require-keys db.host,db.port
Checked 5 file(s)
[MEDIUM] conf/app.ini: line 3: duplicate key 'port' in [main] (first on line 2)
[HIGH] conf/app.ini: line 4: not a section, comment or key=value
[HIGH] conf/bad.json: invalid JSON: unexpected token at '{"db": {"host": "x",}}
[HIGH] conf/good.json: missing required key db.port
[MEDIUM] conf/ok.yml: world-writable (mode 666)
[LOW] conf/ok.yml: line 2: trailing whitespace
[HIGH] conf/tab.yaml: line 2: tab character used for indentation
[HIGH] conf/tab.yaml: invalid YAML: (<unknown>): found character that cannot start any token while scanning for the next token
(exit code 2)
Full script + README on GitHub: ruby-devops-toolkit/config-lint
What you need
Ruby 2.7+ (tested on 3.3.6). Only stdlib: json, yaml, optparse. Works on Linux, macOS and Windows.
The complete code
#!/usr/bin/env ruby
# frozen_string_literal: true
# config_lint.rb - lint a directory of JSON / YAML / INI-style config files before deploy.
# Usage: ruby config_lint.rb DIR [--require-keys db.host,db.port] [--json]
require 'json'
require 'yaml'
require 'optparse'
Issue = Struct.new(:file, :severity, :message)
def dig_path(obj, path)
path.split('.').reduce(obj) { |o, k| o.is_a?(Hash) ? o[k] : (return nil) }
end
def lint_text(path, text, issues)
issues << Issue.new(path, :low, 'no trailing newline') unless text.end_with?("\n") || text.empty?
issues << Issue.new(path, :low, 'mixed CRLF and LF line endings') if text.include?("\r\n") && text.gsub("\r\n", '').include?("\n")
text.each_line.with_index(1) do |l, n|
issues << Issue.new(path, :low, "line #{n}: trailing whitespace") if l.chomp =~ /[ \t]+$/
end
end
def lint_json(path, text, issues, required)
data = JSON.parse(text)
required.each { |k| issues << Issue.new(path, :high, "missing required key #{k}") if dig_path(data, k).nil? }
rescue JSON::ParserError => e
issues << Issue.new(path, :high, "invalid JSON: #{e.message.lines.first.strip[0, 90]}")
end
def lint_yaml(path, text, issues, required)
text.each_line.with_index(1) do |l, n|
issues << Issue.new(path, :high, "line #{n}: tab character used for indentation") if l =~ /\A\t/
end
data = YAML.safe_load(text, aliases: true)
required.each { |k| issues << Issue.new(path, :high, "missing required key #{k}") if dig_path(data, k).nil? } if data.is_a?(Hash)
rescue Psych::Exception => e
issues << Issue.new(path, :high, "invalid YAML: #{e.message[0, 90]}")
end
def lint_ini(path, text, issues)
seen = Hash.new { |h, k| h[k] = {} }
section = '(global)'
text.each_line.with_index(1) do |l, n|
s = l.strip
next if s.empty? || s.start_with?('#', ';')
if s =~ /\A\[(.+)\]\z/ then section = Regexp.last_match(1)
elsif s.include?('=')
key = s.split('=', 2).first.strip
issues << Issue.new(path, :medium, "line #{n}: duplicate key '#{key}' in [#{section}] (first on line #{seen[section][key]})") if seen[section].key?(key)
seen[section][key] ||= n
else
issues << Issue.new(path, :high, "line #{n}: not a section, comment or key=value")
end
end
end
opts = { required: [], json: false }
OptionParser.new do |o|
o.on('--require-keys LIST') { |v| opts[:required] = v.split(',') }
o.on('--json') { opts[:json] = true }
end.parse!
dir = ARGV.shift or abort 'usage: config_lint.rb DIR'
issues = []
files = Dir.glob(File.join(dir, '**', '*.{json,yml,yaml,ini,conf,cfg}')).sort
files.each do |f|
text = File.read(f)
issues << Issue.new(f, :medium, format('world-writable (mode %o)', File.stat(f).mode & 0o777)) if File.stat(f).mode & 0o002 != 0
lint_text(f, text, issues)
case File.extname(f)
when '.json' then lint_json(f, text, issues, opts[:required])
when '.yml', '.yaml' then lint_yaml(f, text, issues, opts[:required])
else lint_ini(f, text, issues)
end
end
if opts[:json]
puts JSON.pretty_generate(files: files.size, issues: issues.map(&:to_h))
else
puts "Checked #{files.size} file(s)"
issues.each { |i| puts "[#{i.severity.to_s.upcase}] #{i.file}: #{i.message}" }
puts 'OK: all configs clean' if issues.empty?
end
exit(issues.any? { |i| i.severity == :high } ? 2 : (issues.empty? ? 0 : 1))
Step-by-step walkthrough
1. Find files
Dir.glob with a brace pattern collects .json .yml .yaml .ini .conf .cfg recursively and sorts them for stable output.
2. Format-independent checks
Trailing newline, mixed CRLF/LF, trailing whitespace per line, and world-writable permission bits via File.stat(f).mode & 0o002.
3. Format-specific checks
JSON and YAML are parsed (YAML with safe_load, so nothing executes); parse errors become high-severity issues. YAML tab indentation is caught before parsing for a clearer message. INI is scanned line by line to find duplicate keys per section and lines that are neither comments, sections nor key=value.
4. Required keys
--require-keys db.host,db.port walks dotted paths into the parsed data and reports any that are missing. Exit code 2 on any high finding makes CI fail.
What you will see
$ ruby config_lint.rb conf/ --require-keys db.host,db.port
Checked 5 file(s)
[MEDIUM] conf/app.ini: line 3: duplicate key 'port' in [main] (first on line 2)
[HIGH] conf/app.ini: line 4: not a section, comment or key=value
[HIGH] conf/bad.json: invalid JSON: unexpected token at '{"db": {"host": "x",}}
[HIGH] conf/good.json: missing required key db.port
[MEDIUM] conf/ok.yml: world-writable (mode 666)
[LOW] conf/ok.yml: line 2: trailing whitespace
[HIGH] conf/tab.yaml: line 2: tab character used for indentation
[HIGH] conf/tab.yaml: invalid YAML: (<unknown>): found character that cannot start any token while scanning for the next token
(exit code 2)
When it does not work
- Permission check is meaningless on Windows file systems; ignore that line there.
- YAML aliases are allowed; anchors to custom classes will raise and be reported as invalid.
- Large trees: add
--excludehandling or narrow the glob.
Extending the script
- Support TOML via the
toml-rbgem. - Validate against JSON Schema with
json_schemer. - Add a
--fixmode for whitespace and newline issues.