the shed // ruby // cross-platform // config

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_lint.rb

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)
Get the code

Full script + README on GitHub: ruby-devops-toolkit/config-lint

prerequisites

What you need

Ruby 2.7+ (tested on 3.3.6). Only stdlib: json, yaml, optparse. Works on Linux, macOS and Windows.

config_lint.rb pipeline diagram

config_lint.rb pipeline
full script

The complete code

config_lint.rbruby
#!/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))
how it works

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.

example output

What you will see

terminaltext
$ 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)
troubleshooting

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 --exclude handling or narrow the glob.
next steps

Extending the script

  • Support TOML via the toml-rb gem.
  • Validate against JSON Schema with json_schemer.
  • Add a --fix mode for whitespace and newline issues.

Tha-Shed

Type a brief, get a finished film. Characters, voices and music included.

Start creating