envdoctor 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ec3513e2cfdb6870cd1608bf7d5a3cf2c831113187ff3ca34ed6fe9539a231d5
4
+ data.tar.gz: 76b4d2234288c38d2c20c27ee39db004a69da9adfe945a7ef6a856a5e56f7f5b
5
+ SHA512:
6
+ metadata.gz: fc16e6e1ca03e96d1e72a88a05391370afc810790dae1d338a77759d9152b3cdb0ad6ecfd86d51faf093196144eeb2ba66ba352822c3859718114c5a0e0a9fc0
7
+ data.tar.gz: fada228584d093fb6fc71ecc9dbda5cd2d7fe20b66c646e490383c981e87d01db9b2d3e56fb79ca0a5ac8d831e9fcfd569d7d42e837aca2af1bff8513d9a7e47
data/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # envdoctor (Ruby)
2
+
3
+ Native Ruby port of [envdoctor](https://github.com/arun-skg/envdoctor) — a
4
+ local-first environment-variable consistency checker, packaged as a gem.
5
+
6
+ ```bash
7
+ gem install envdoctor
8
+ envdoctor scan --dir .
9
+ ```
10
+
11
+ ## What it does
12
+
13
+ Reconciles variables **used** in Ruby source (`ENV["X"]`, `ENV['X']`,
14
+ `ENV.fetch("X")`) against those **defined** in `.env` files:
15
+
16
+ | Rule | Severity | Meaning |
17
+ |------|----------|---------|
18
+ | `undefined-in-source` | error | Used in code but not defined in any `.env` file |
19
+ | `unused` | warning | Defined in `.env` but never referenced in source |
20
+
21
+ Comments and `=begin/=end` blocks are stripped before scanning. `scan` exits
22
+ `1` on errors (or warnings with `--strict`). Values are never printed.
23
+
24
+ ## Development
25
+
26
+ ```bash
27
+ cd ruby
28
+ ruby -Ilib test/test_scanner.rb
29
+ gem build envdoctor.gemspec
30
+ ```
31
+
32
+ One of several native, per-ecosystem ports; the reference implementation lives
33
+ in the [main repository](https://github.com/arun-skg/envdoctor).
data/exe/envdoctor ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/envdoctor"
5
+ exit Envdoctor::CLI.run(ARGV)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "scanner"
5
+
6
+ module Envdoctor
7
+ # Command-line entry point.
8
+ module CLI
9
+ module_function
10
+
11
+ def run(argv)
12
+ dir = "."
13
+ strict = false
14
+ parser = OptionParser.new do |o|
15
+ o.banner = "Usage: envdoctor scan [options]"
16
+ o.on("-d", "--dir DIR", "Project root (default: cwd)") { |v| dir = v }
17
+ o.on("--strict", "Treat warnings as errors") { strict = true }
18
+ end
19
+ args = argv.dup
20
+ args.shift if args.first == "scan"
21
+ parser.parse!(args)
22
+
23
+ root = File.expand_path(dir)
24
+ findings = Scanner.scan(root)
25
+ errors = findings.select { |f| f.severity == "error" }
26
+ warnings = findings.select { |f| f.severity == "warning" }
27
+
28
+ puts "ENVIRONMENT AUDIT"
29
+ puts "=" * 40
30
+ if findings.empty?
31
+ puts "\nNo issues found."
32
+ return 0
33
+ end
34
+
35
+ unless errors.empty?
36
+ puts "\nErrors"
37
+ errors.each { |f| puts " x #{f.name} #{f.origin.file}:#{f.origin.line} #{f.message}" }
38
+ end
39
+ unless warnings.empty?
40
+ puts "\nWarnings"
41
+ warnings.each { |f| puts " ! #{f.name} #{f.origin.file}:#{f.origin.line} #{f.message}" }
42
+ end
43
+ puts "\nSummary: #{errors.length} error(s), #{warnings.length} warning(s)"
44
+
45
+ (!errors.empty? || (strict && !warnings.empty?)) ? 1 : 0
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envdoctor
4
+ # Core scanner: reconcile ENV usage in Ruby source against .env definitions.
5
+ # Local-first — no network, values never printed.
6
+ module Scanner
7
+ module_function
8
+
9
+ USAGE_PATTERNS = [
10
+ /\bENV\[\s*["']([A-Za-z_]\w*)["']\s*\]/,
11
+ /\bENV\.fetch\(\s*["']([A-Za-z_]\w*)["']/
12
+ ].freeze
13
+
14
+ ENV_LINE = /\A\s*(?:export\s+)?([A-Za-z_]\w*)\s*=/.freeze
15
+
16
+ Origin = Struct.new(:file, :line)
17
+ Finding = Struct.new(:rule, :severity, :name, :message, :origin)
18
+
19
+ # Blank comments and =begin/=end blocks, preserving line structure.
20
+ def strip_noise(code)
21
+ code = code.gsub(/^=begin\b.*?^=end\b[^\n]*/m) { |m| m.gsub(/[^\n]/, " ") }
22
+ code.gsub(/#[^\n]*/) { |m| " " * m.length }
23
+ end
24
+
25
+ def scan_source(path, content)
26
+ text = strip_noise(content)
27
+ used = {}
28
+ USAGE_PATTERNS.each do |re|
29
+ text.to_enum(:scan, re).each do
30
+ match = Regexp.last_match
31
+ name = match[1]
32
+ next if used.key?(name)
33
+
34
+ line = text[0...match.begin(0)].count("\n") + 1
35
+ used[name] = Origin.new(path, line)
36
+ end
37
+ end
38
+ used
39
+ end
40
+
41
+ def parse_env(path, content)
42
+ defined = {}
43
+ content.split("\n").each_with_index do |raw, i|
44
+ stripped = raw.strip
45
+ next if stripped.empty? || stripped.start_with?("#")
46
+
47
+ if (m = raw.match(ENV_LINE))
48
+ defined[m[1]] ||= Origin.new(path, i + 1)
49
+ end
50
+ end
51
+ defined
52
+ end
53
+
54
+ def discover_env_files(root)
55
+ files = Dir.glob(File.join(root, ".env"))
56
+ files += Dir.glob(File.join(root, ".env.*")).reject { |f| f.end_with?(".example") }
57
+ files.sort
58
+ end
59
+
60
+ def discover_source_files(root)
61
+ Dir.glob(File.join(root, "**", "*.rb")).reject do |p|
62
+ p.split(File::SEPARATOR).any? { |part| %w[.git vendor node_modules].include?(part) }
63
+ end.sort
64
+ end
65
+
66
+ def scan(root)
67
+ defined = {}
68
+ discover_env_files(root).each do |f|
69
+ parse_env(relative(root, f), File.read(f)).each { |k, v| defined[k] ||= v }
70
+ end
71
+
72
+ used = {}
73
+ discover_source_files(root).each do |f|
74
+ scan_source(relative(root, f), File.read(f)).each { |k, v| used[k] ||= v }
75
+ end
76
+
77
+ findings = []
78
+ used.keys.sort.each do |name|
79
+ next if defined.key?(name)
80
+
81
+ findings << Finding.new("undefined-in-source", "error", name,
82
+ "used in source code but not defined in any environment file",
83
+ used[name])
84
+ end
85
+ defined.keys.sort.each do |name|
86
+ next if used.key?(name)
87
+
88
+ findings << Finding.new("unused", "warning", name,
89
+ "defined but never referenced in source", defined[name])
90
+ end
91
+ findings
92
+ end
93
+
94
+ def relative(root, path)
95
+ path.sub(/\A#{Regexp.escape(root)}#{Regexp.escape(File::SEPARATOR)}?/, "")
96
+ end
97
+ end
98
+ end
data/lib/envdoctor.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "envdoctor/scanner"
4
+ require_relative "envdoctor/cli"
5
+
6
+ module Envdoctor
7
+ VERSION = "0.1.0"
8
+ end
metadata ADDED
@@ -0,0 +1,51 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: envdoctor
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Arun Natesan
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-08-22 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: 'Reconciles ENV usage in Ruby source against .env files: reports undefined-in-source
14
+ (error) and unused (warning). Local-first, no network.'
15
+ email:
16
+ executables:
17
+ - envdoctor
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - README.md
22
+ - exe/envdoctor
23
+ - lib/envdoctor.rb
24
+ - lib/envdoctor/cli.rb
25
+ - lib/envdoctor/scanner.rb
26
+ homepage: https://github.com/arun-skg/envdoctor
27
+ licenses:
28
+ - MIT
29
+ metadata:
30
+ source_code_uri: https://github.com/arun-skg/envdoctor
31
+ rubygems_mfa_required: 'true'
32
+ post_install_message:
33
+ rdoc_options: []
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '2.6'
41
+ required_rubygems_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ requirements: []
47
+ rubygems_version: 3.5.22
48
+ signing_key:
49
+ specification_version: 4
50
+ summary: Local-first consistency checker for environment variables (native Ruby port)
51
+ test_files: []