envdoctor 0.1.0 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec3513e2cfdb6870cd1608bf7d5a3cf2c831113187ff3ca34ed6fe9539a231d5
4
- data.tar.gz: 76b4d2234288c38d2c20c27ee39db004a69da9adfe945a7ef6a856a5e56f7f5b
3
+ metadata.gz: 2d2670c59c370dc3e03b3f3e91a28285d064670f6551093d6775eb235ec2b6bb
4
+ data.tar.gz: 82c7e7a3c9fa5bb3c171cb78cbd90c646a54f699d8063afdf8ce847f4e593082
5
5
  SHA512:
6
- metadata.gz: fc16e6e1ca03e96d1e72a88a05391370afc810790dae1d338a77759d9152b3cdb0ad6ecfd86d51faf093196144eeb2ba66ba352822c3859718114c5a0e0a9fc0
7
- data.tar.gz: fada228584d093fb6fc71ecc9dbda5cd2d7fe20b66c646e490383c981e87d01db9b2d3e56fb79ca0a5ac8d831e9fcfd569d7d42e837aca2af1bff8513d9a7e47
6
+ metadata.gz: 9a800d6307b081f2509adf2a262de63c5bae54d0b29a441ffb8dfc6daa746097e94410126e255cdaf2c94b8f8aa10667a6955bf4f3ca85284bd6bf0b19cd6ba1
7
+ data.tar.gz: fc5b091e8345c4da7c7fe02dfa5df00319ef12b8e437b9f910733dadb375e7ab6061773adaed398c09df120f3b27fd87891cec31967503021fcab6322f919fbe
data/README.md CHANGED
@@ -3,23 +3,49 @@
3
3
  Native Ruby port of [envdoctor](https://github.com/arun-skg/envdoctor) — a
4
4
  local-first environment-variable consistency checker, packaged as a gem.
5
5
 
6
+ ## Install
7
+
6
8
  ```bash
7
9
  gem install envdoctor
8
- envdoctor scan --dir .
9
10
  ```
10
11
 
11
- ## What it does
12
+ ## Quick start
13
+
14
+ ```bash
15
+ envdoctor scan --dir . # audit; exit 1 on errors
16
+ envdoctor scan --strict # treat warnings as errors too
17
+ envdoctor scan --json # emit findings as a JSON array (values never included)
18
+ ```
19
+
20
+ ## What it detects
12
21
 
13
22
  Reconciles variables **used** in Ruby source (`ENV["X"]`, `ENV['X']`,
14
- `ENV.fetch("X")`) against those **defined** in `.env` files:
23
+ `ENV.fetch("X")`) against those **defined** in `.env` files. Interpolated
24
+ references in **Docker Compose** (`${VAR}`), **GitHub Actions** workflows
25
+ (`${{ secrets.X }}`, `${{ vars.X }}`, `${{ env.X }}`) and **Kubernetes**
26
+ manifests (`${VAR}`) also count as usage, so those files feed the same
27
+ missing/undefined and unused checks:
15
28
 
16
29
  | Rule | Severity | Meaning |
17
30
  |------|----------|---------|
18
- | `undefined-in-source` | error | Used in code but not defined in any `.env` file |
31
+ | `undefined-in-source` | error | Referenced (in source or infra files) but not defined in any `.env` file |
32
+ | `duplicates` | error | Same key defined 2+ times in a single `.env` file |
33
+ | `public-prefix` | error | Secret-looking variable exposed to client bundles via a public prefix (`NEXT_PUBLIC_`, `VITE_`, `REACT_APP_`, …) |
34
+ | `type-mismatch` | error | Variable's inferred value type differs across environments (e.g. integer vs string) |
19
35
  | `unused` | warning | Defined in `.env` but never referenced in source |
36
+ | `environment-diff` | warning | Defined in some environments but missing from others |
37
+ | `weak-secret` | warning | Secret-looking variable has an empty, short, or placeholder value |
38
+ | `typo` | warning | Used name closely matches a defined name (likely misspelling) |
39
+
40
+ Environment labels come from the `.env` filename (`.env`→`default`,
41
+ `.env.local`→`local`, `.env.production`→`production`,
42
+ `.env.production.local`→`production`); `*.example` files are skipped. Values are
43
+ read only to power detection and are **never** included in any output.
20
44
 
21
45
  Comments and `=begin/=end` blocks are stripped before scanning. `scan` exits
22
- `1` on errors (or warnings with `--strict`). Values are never printed.
46
+ `1` on errors (or warnings with `--strict`). Pass `--json` to emit the findings
47
+ as a JSON array (keys: `rule`, `severity`, `name`, `message`, `file`, `line`) —
48
+ still without any values.
23
49
 
24
50
  ## Development
25
51
 
@@ -29,5 +55,47 @@ ruby -Ilib test/test_scanner.rb
29
55
  gem build envdoctor.gemspec
30
56
  ```
31
57
 
32
- One of several native, per-ecosystem ports; the reference implementation lives
33
- in the [main repository](https://github.com/arun-skg/envdoctor).
58
+ ## Subcommands
59
+
60
+ Alongside `scan`, every port shares two environment subcommands:
61
+
62
+ ```bash
63
+ envdoctor diff <envA> <envB> # compare two environments (add --json)
64
+ envdoctor sync <from> <to> # copy missing keys (add --dry-run)
65
+ envdoctor init # generate .env.example + ENVIRONMENT.md (add --force)
66
+ envdoctor fix # always (re)generate both files
67
+ ```
68
+
69
+ `diff` reports which variable names are only in one environment; `sync` appends
70
+ the missing keys to the target `.env` file as empty `KEY=` placeholders — values
71
+ are never copied.
72
+
73
+ `init` / `fix` generate two files at the project root from the union of defined
74
+ (`.env*`) and used (source + Compose/Actions/K8s) variable names: `.env.example`
75
+ (one `KEY=` per variable) and `ENVIRONMENT.md` (a Defined/Used table). Values are
76
+ never written. `init` writes each file only if absent (`--force` overwrites);
77
+ `fix` always rewrites both. Both accept `-d/--dir PATH`.
78
+
79
+ ## Schema validation
80
+
81
+ Add an `envdoctor.schema.json` at your project root to validate `.env` values:
82
+
83
+ ```json
84
+ {
85
+ "PORT": { "type": "integer", "min": 1, "max": 65535 },
86
+ "LEVEL": { "enum": ["debug", "info", "warn", "error"] },
87
+ "TOKEN": { "type": "string", "optional": true }
88
+ }
89
+ ```
90
+
91
+ Supported rule fields: `type` (string/integer/float/boolean/url/json), `enum`,
92
+ `regex`, `min`, `max`, `optional`. Values that fail are reported as
93
+ `schema-validation` errors (values are never printed).
94
+
95
+ ## Other languages
96
+
97
+ envdoctor ships as a standalone native port for each ecosystem:
98
+
99
+ - [Node (reference)](..) · [Python](../python) · [Go](../go) · [PHP](../php) · [Java](../java) · [Perl](../perl)
100
+ - 📖 Docs: [arun-skg.github.io/envdoctor](https://arun-skg.github.io/envdoctor/)
101
+ - Main repository: [github.com/arun-skg/envdoctor](https://github.com/arun-skg/envdoctor)
data/lib/envdoctor/cli.rb CHANGED
@@ -9,12 +9,19 @@ module Envdoctor
9
9
  module_function
10
10
 
11
11
  def run(argv)
12
+ return run_diff(argv[1..]) if argv.first == "diff"
13
+ return run_sync(argv[1..]) if argv.first == "sync"
14
+ return run_init(argv[1..]) if argv.first == "init"
15
+ return run_fix(argv[1..]) if argv.first == "fix"
16
+
12
17
  dir = "."
13
18
  strict = false
19
+ json = false
14
20
  parser = OptionParser.new do |o|
15
21
  o.banner = "Usage: envdoctor scan [options]"
16
22
  o.on("-d", "--dir DIR", "Project root (default: cwd)") { |v| dir = v }
17
23
  o.on("--strict", "Treat warnings as errors") { strict = true }
24
+ o.on("--json", "Emit findings as a JSON array") { json = true }
18
25
  end
19
26
  args = argv.dup
20
27
  args.shift if args.first == "scan"
@@ -25,6 +32,11 @@ module Envdoctor
25
32
  errors = findings.select { |f| f.severity == "error" }
26
33
  warnings = findings.select { |f| f.severity == "warning" }
27
34
 
35
+ if json
36
+ puts Scanner.to_json_array(findings)
37
+ return (!errors.empty? || (strict && !warnings.empty?)) ? 1 : 0
38
+ end
39
+
28
40
  puts "ENVIRONMENT AUDIT"
29
41
  puts "=" * 40
30
42
  if findings.empty?
@@ -32,17 +44,118 @@ module Envdoctor
32
44
  return 0
33
45
  end
34
46
 
47
+ loc = ->(f) { f.origin ? " #{f.origin.file}:#{f.origin.line}" : "" }
35
48
  unless errors.empty?
36
49
  puts "\nErrors"
37
- errors.each { |f| puts " x #{f.name} #{f.origin.file}:#{f.origin.line} #{f.message}" }
50
+ errors.each { |f| puts " x #{f.name}#{loc.call(f)} #{f.message}" }
38
51
  end
39
52
  unless warnings.empty?
40
53
  puts "\nWarnings"
41
- warnings.each { |f| puts " ! #{f.name} #{f.origin.file}:#{f.origin.line} #{f.message}" }
54
+ warnings.each { |f| puts " ! #{f.name}#{loc.call(f)} #{f.message}" }
42
55
  end
43
56
  puts "\nSummary: #{errors.length} error(s), #{warnings.length} warning(s)"
44
57
 
45
58
  (!errors.empty? || (strict && !warnings.empty?)) ? 1 : 0
46
59
  end
60
+
61
+ def run_diff(argv)
62
+ dir = "."
63
+ json = false
64
+ pos = []
65
+ OptionParser.new do |o|
66
+ o.on("-d", "--dir DIR") { |v| dir = v }
67
+ o.on("--json") { json = true }
68
+ end.order!(argv.dup) { |a| pos << a }
69
+ a, b = pos[0], pos[1]
70
+ d = Scanner.diff_labels(File.expand_path(dir), a, b)
71
+ if json
72
+ require "json"
73
+ puts JSON.generate({ "a" => a, "b" => b }.merge(d))
74
+ return 0
75
+ end
76
+ puts "ENVIRONMENT DIFF: #{a} vs #{b}"
77
+ puts "=" * 40
78
+ unless d["onlyInA"].empty?
79
+ puts "Only in #{a}:"
80
+ d["onlyInA"].each { |k| puts " + #{k}" }
81
+ end
82
+ unless d["onlyInB"].empty?
83
+ puts "Only in #{b}:"
84
+ d["onlyInB"].each { |k| puts " + #{k}" }
85
+ end
86
+ puts "Common: #{d['common'].length} variable(s)"
87
+ 0
88
+ end
89
+
90
+ # Generate `.env.example` and `ENVIRONMENT.md`, writing each only if absent
91
+ # (or always with --force).
92
+ def run_init(argv)
93
+ dir = "."
94
+ force = false
95
+ OptionParser.new do |o|
96
+ o.on("-d", "--dir DIR") { |v| dir = v }
97
+ o.on("--force") { force = true }
98
+ end.parse!(argv.dup)
99
+
100
+ root = File.expand_path(dir)
101
+ files = {
102
+ ".env.example" => Scanner.env_example_content(root),
103
+ "ENVIRONMENT.md" => Scanner.environment_doc_content(root)
104
+ }
105
+ files.each do |name, content|
106
+ path = File.join(root, name)
107
+ if File.exist?(path) && !force
108
+ puts "skipped #{name} (exists)"
109
+ else
110
+ File.write(path, content)
111
+ puts "#{force ? 'wrote' : 'created'} #{name}"
112
+ end
113
+ end
114
+ 0
115
+ end
116
+
117
+ # Always (re)write both generated files.
118
+ def run_fix(argv)
119
+ dir = "."
120
+ OptionParser.new do |o|
121
+ o.on("-d", "--dir DIR") { |v| dir = v }
122
+ end.parse!(argv.dup)
123
+
124
+ root = File.expand_path(dir)
125
+ example = Scanner.env_example_content(root)
126
+ doc = Scanner.environment_doc_content(root)
127
+ File.write(File.join(root, ".env.example"), example)
128
+ puts "wrote .env.example"
129
+ File.write(File.join(root, "ENVIRONMENT.md"), doc)
130
+ puts "wrote ENVIRONMENT.md"
131
+ 0
132
+ end
133
+
134
+ def run_sync(argv)
135
+ dir = "."
136
+ json = false
137
+ dry = false
138
+ pos = []
139
+ OptionParser.new do |o|
140
+ o.on("-d", "--dir DIR") { |v| dir = v }
141
+ o.on("--dry-run") { dry = true }
142
+ o.on("--json") { json = true }
143
+ end.order!(argv.dup) { |a| pos << a }
144
+ from, to = pos[0], pos[1]
145
+ added = Scanner.sync_labels(File.expand_path(dir), from, to, dry_run: dry)
146
+ if json
147
+ require "json"
148
+ puts JSON.generate({ "from" => from, "to" => to, "added" => added, "dryRun" => dry })
149
+ return 0
150
+ end
151
+ if added.empty?
152
+ puts "Already in sync."
153
+ return 0
154
+ end
155
+ verb = dry ? "Would sync" : "Synced"
156
+ puts "#{verb} #{added.length} variable(s) from #{from} to #{to}:"
157
+ added.each { |k| puts " + #{k}" }
158
+ 0
159
+ end
47
160
  end
48
161
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Envdoctor
4
6
  # Core scanner: reconcile ENV usage in Ruby source against .env definitions.
5
7
  # Local-first — no network, values never printed.
@@ -13,7 +15,18 @@ module Envdoctor
13
15
 
14
16
  ENV_LINE = /\A\s*(?:export\s+)?([A-Za-z_]\w*)\s*=/.freeze
15
17
 
18
+ PUBLIC_PREFIXES = %w[
19
+ NEXT_PUBLIC_ VITE_ REACT_APP_ EXPO_PUBLIC_ GATSBY_ NUXT_PUBLIC_ VUE_APP_ PUBLIC_
20
+ ].freeze
21
+
22
+ SECRET_RE = /SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|API_?KEY|ACCESS_?KEY|AUTH/i.freeze
23
+
24
+ WEAK_VALUE_RE =
25
+ /\A(changeme|change_me|placeholder|x{3,}|todo|secret|password|passwd|test|example|sample|dummy|your[_-].*|<.*>|\$\{.*\})\z/i.freeze
26
+
16
27
  Origin = Struct.new(:file, :line)
28
+ # A single .env definition occurrence: its line and parsed value.
29
+ Definition = Struct.new(:line, :value)
17
30
  Finding = Struct.new(:rule, :severity, :name, :message, :origin)
18
31
 
19
32
  # Blank comments and =begin/=end blocks, preserving line structure.
@@ -38,6 +51,21 @@ module Envdoctor
38
51
  used
39
52
  end
40
53
 
54
+ # Parse the VALUE to the right of the first `=`: trim, then strip one pair
55
+ # of matching surrounding quotes. Values are used ONLY for detection and are
56
+ # never surfaced in any output.
57
+ def parse_value(raw)
58
+ idx = raw.index("=")
59
+ return "" if idx.nil?
60
+
61
+ value = raw[(idx + 1)..].to_s.strip
62
+ if value.length >= 2 && %w[" '].include?(value[0]) && value[-1] == value[0]
63
+ value = value[1..-2]
64
+ end
65
+ value
66
+ end
67
+
68
+ # Returns { name => [Definition, ...] } with ALL occurrences per key in order.
41
69
  def parse_env(path, content)
42
70
  defined = {}
43
71
  content.split("\n").each_with_index do |raw, i|
@@ -45,12 +73,70 @@ module Envdoctor
45
73
  next if stripped.empty? || stripped.start_with?("#")
46
74
 
47
75
  if (m = raw.match(ENV_LINE))
48
- defined[m[1]] ||= Origin.new(path, i + 1)
76
+ (defined[m[1]] ||= []) << Definition.new(i + 1, parse_value(raw))
49
77
  end
50
78
  end
51
79
  defined
52
80
  end
53
81
 
82
+ # Derive the environment label from a .env filename.
83
+ def env_label(filename)
84
+ base = File.basename(filename)
85
+ return "default" if base == ".env"
86
+
87
+ label = base.sub(/\A\.env\./, "")
88
+ label = label.sub(/\.local\z/, "") if label.end_with?(".local")
89
+ label
90
+ end
91
+
92
+ def public_prefix?(name)
93
+ PUBLIC_PREFIXES.any? { |p| name.start_with?(p) } && SECRET_RE.match?(name)
94
+ end
95
+
96
+ # Infer the coarse type of a value string.
97
+ def infer_type(value)
98
+ return "empty" if value.empty?
99
+ return "integer" if value.match?(/\A-?\d+\z/)
100
+ return "float" if value.match?(/\A-?\d+\.\d+\z/)
101
+ return "boolean" if value.match?(/\A(true|false)\z/i)
102
+ return "url" if value.match?(%r{\Ahttps?://})
103
+
104
+ if value.start_with?("{", "[")
105
+ begin
106
+ JSON.parse(value)
107
+ return "json"
108
+ rescue JSON::ParserError
109
+ # fall through to string
110
+ end
111
+ end
112
+ "string"
113
+ end
114
+
115
+ # Compatibility group for an inferred type (integer/float collapse to numeric).
116
+ def type_group(type)
117
+ %w[integer float].include?(type) ? "numeric" : type
118
+ end
119
+
120
+ def weak_secret?(value)
121
+ value.empty? || value.length < 8 || WEAK_VALUE_RE.match?(value)
122
+ end
123
+
124
+ def levenshtein(a, b)
125
+ return b.length if a.empty?
126
+ return a.length if b.empty?
127
+
128
+ prev = (0..b.length).to_a
129
+ a.each_char.with_index do |ca, i|
130
+ curr = [i + 1]
131
+ b.each_char.with_index do |cb, j|
132
+ cost = ca == cb ? 0 : 1
133
+ curr << [curr[j] + 1, prev[j + 1] + 1, prev[j] + cost].min
134
+ end
135
+ prev = curr
136
+ end
137
+ prev[b.length]
138
+ end
139
+
54
140
  def discover_env_files(root)
55
141
  files = Dir.glob(File.join(root, ".env"))
56
142
  files += Dir.glob(File.join(root, ".env.*")).reject { |f| f.end_with?(".example") }
@@ -63,32 +149,345 @@ module Envdoctor
63
149
  end.sort
64
150
  end
65
151
 
66
- def scan(root)
152
+ COMPOSE_RE = /\A(docker-)?compose([.-].*)?\.ya?ml\z/i.freeze
153
+
154
+ def skip_infra_path?(path)
155
+ path.split(File::SEPARATOR).any? { |part| %w[.git vendor node_modules target].include?(part) }
156
+ end
157
+
158
+ # Walk the project and classify infra YAML files. Returns [[path, kind], ...]
159
+ # where kind is one of :compose, :actions, :k8s.
160
+ def discover_infra_files(root)
161
+ out = []
162
+ Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).each do |path|
163
+ next unless File.file?(path)
164
+ next if skip_infra_path?(path)
165
+
166
+ base = File.basename(path)
167
+ segments = path.split(File::SEPARATOR)
168
+ if base.match?(COMPOSE_RE)
169
+ out << [path, :compose]
170
+ elsif base =~ /\.ya?ml\z/i &&
171
+ segments.each_cons(2).any? { |a, b| a == ".github" && b == "workflows" }
172
+ out << [path, :actions]
173
+ elsif base =~ /\.ya?ml\z/i
174
+ content = File.read(path)
175
+ out << [path, :k8s] if content.match?(/^apiVersion:/m) && content.match?(/^kind:/m)
176
+ end
177
+ end
178
+ out.sort_by(&:first)
179
+ end
180
+
181
+ INTERP_PATTERNS = [
182
+ /\$\{([A-Za-z_][A-Za-z0-9_]*)/,
183
+ /\$([A-Za-z_][A-Za-z0-9_]*)/
184
+ ].freeze
185
+
186
+ ACTIONS_CONTEXT_RE = /\b(?:secrets|vars|env)\.([A-Za-z_][A-Za-z0-9_]*)/.freeze
187
+
188
+ # Extract used variable NAMES (with origin) from an infra YAML file. First
189
+ # removes escaped `$$` (two spaces, offsets preserved), then scans for
190
+ # interpolation and, for GitHub Actions, the secrets/vars/env contexts.
191
+ def scan_infra(path, content, kind)
192
+ text = content.gsub("$$", " ")
193
+ used = {}
194
+ patterns = INTERP_PATTERNS.dup
195
+ patterns << ACTIONS_CONTEXT_RE if kind == :actions
196
+ patterns.each do |re|
197
+ text.to_enum(:scan, re).each do
198
+ match = Regexp.last_match
199
+ name = match[1]
200
+ next if used.key?(name)
201
+
202
+ line = text[0...match.begin(0)].count("\n") + 1
203
+ used[name] = Origin.new(path, line)
204
+ end
205
+ end
206
+ used
207
+ end
208
+
209
+ # Map each environment label to the set of variable names defined in it.
210
+ def defined_by_label(root)
211
+ labels = {}
212
+ discover_env_files(root).each do |f|
213
+ set = (labels[env_label(f)] ||= [])
214
+ parse_env(f, File.read(f)).each_key { |name| set << name unless set.include?(name) }
215
+ end
216
+ labels
217
+ end
218
+
219
+ def diff_labels(root, a, b)
220
+ labels = defined_by_label(root)
221
+ da = labels[a] || []
222
+ db = labels[b] || []
223
+ {
224
+ "onlyInA" => (da - db).sort,
225
+ "onlyInB" => (db - da).sort,
226
+ "common" => (da & db).sort
227
+ }
228
+ end
229
+
230
+ # Append keys present in `from` but missing from `to` as `KEY=` placeholders.
231
+ # Values are never copied.
232
+ def sync_labels(root, from, to, dry_run: false)
233
+ labels = defined_by_label(root)
234
+ missing = ((labels[from] || []) - (labels[to] || [])).sort
235
+ if !missing.empty? && !dry_run
236
+ target = File.join(root, to == "default" ? ".env" : ".env.#{to}")
237
+ existing = File.exist?(target) ? File.read(target) : ""
238
+ prefix = (existing.empty? || existing.end_with?("\n")) ? "" : "\n"
239
+ File.open(target, "a") { |fh| fh.write(prefix + missing.map { |k| "#{k}=\n" }.join) }
240
+ end
241
+ missing
242
+ end
243
+
244
+ # Collect the DEFINED (from .env*) and USED (source + infra) name sets for a
245
+ # project. Returns [defined_hash, used_hash] with name => true entries.
246
+ def collect_names(root)
67
247
  defined = {}
68
248
  discover_env_files(root).each do |f|
69
- parse_env(relative(root, f), File.read(f)).each { |k, v| defined[k] ||= v }
249
+ parse_env(relative(root, f), File.read(f)).each_key { |name| defined[name] = true }
250
+ end
251
+ used = {}
252
+ discover_source_files(root).each do |f|
253
+ scan_source(relative(root, f), File.read(f)).each_key { |k| used[k] = true }
254
+ end
255
+ discover_infra_files(root).each do |f, kind|
256
+ scan_infra(relative(root, f), File.read(f), kind).each_key { |k| used[k] = true }
257
+ end
258
+ [defined, used]
259
+ end
260
+
261
+ # Exact `.env.example` content: header, then `NAME=` per (defined ∪ used),
262
+ # sorted ascending. Values are never written.
263
+ def env_example_content(root)
264
+ defined, used = collect_names(root)
265
+ names = (defined.keys + used.keys).uniq.sort
266
+ "# Generated by envdoctor. Fill in values; do not commit secrets.\n" +
267
+ names.map { |n| "#{n}=\n" }.join
268
+ end
269
+
270
+ # Exact `ENVIRONMENT.md` content: a table of Defined/Used per variable.
271
+ def environment_doc_content(root)
272
+ defined, used = collect_names(root)
273
+ names = (defined.keys + used.keys).uniq.sort
274
+ lines = ["# Environment variables", "", "| Variable | Defined | Used |", "| --- | --- | --- |"]
275
+ names.each do |n|
276
+ lines << "| #{n} | #{defined.key?(n) ? 'yes' : 'no'} | #{used.key?(n) ? 'yes' : 'no'} |"
277
+ end
278
+ lines.join("\n") + "\n"
279
+ end
280
+
281
+ def scan(root)
282
+ defined = {} # name => Origin (first definition wins)
283
+ defined_value = {} # name => value at first definition
284
+ labels_of = {} # name => { label => value } (first value per label)
285
+ project_labels = []
286
+ dup_findings = []
287
+
288
+ discover_env_files(root).each do |f|
289
+ rel = relative(root, f)
290
+ label = env_label(f)
291
+ project_labels << label unless project_labels.include?(label)
292
+ parse_env(rel, File.read(f)).each do |name, defs|
293
+ if defs.length >= 2
294
+ lines = defs.map(&:line)
295
+ dup_findings << Finding.new("duplicates", "error", name,
296
+ "defined #{defs.length} times in the same file " \
297
+ "(lines #{lines.join(', ')})",
298
+ Origin.new(rel, defs.first.line))
299
+ end
300
+ # First occurrence (first file wins) counts as the definition.
301
+ unless defined.key?(name)
302
+ defined[name] = Origin.new(rel, defs.first.line)
303
+ defined_value[name] = defs.first.value
304
+ end
305
+ bucket = (labels_of[name] ||= {})
306
+ bucket[label] = defs.first.value unless bucket.key?(label)
307
+ end
70
308
  end
71
309
 
72
310
  used = {}
73
311
  discover_source_files(root).each do |f|
74
312
  scan_source(relative(root, f), File.read(f)).each { |k, v| used[k] ||= v }
75
313
  end
314
+ # Infra sources (Docker Compose / GitHub Actions / Kubernetes) contribute
315
+ # additional used names; first origin wins.
316
+ discover_infra_files(root).each do |f, kind|
317
+ scan_infra(relative(root, f), File.read(f), kind).each { |k, v| used[k] ||= v }
318
+ end
319
+
320
+ errors = []
321
+ warnings = []
76
322
 
77
- findings = []
323
+ # --- errors: undefined-in-source ---
78
324
  used.keys.sort.each do |name|
79
325
  next if defined.key?(name)
80
326
 
81
- findings << Finding.new("undefined-in-source", "error", name,
82
- "used in source code but not defined in any environment file",
83
- used[name])
327
+ errors << Finding.new("undefined-in-source", "error", name,
328
+ "referenced but not defined in any environment file",
329
+ used[name])
84
330
  end
331
+
332
+ # --- errors: duplicates ---
333
+ dup_findings.sort_by(&:name).each { |finding| errors << finding }
334
+
335
+ # --- errors: public-prefix ---
336
+ defined.keys.sort.each do |name|
337
+ next unless public_prefix?(name)
338
+
339
+ errors << Finding.new("public-prefix", "error", name,
340
+ "secret-looking variable is exposed to client bundles " \
341
+ "via a public prefix", defined[name])
342
+ end
343
+
344
+ # --- errors: type-mismatch ---
345
+ defined.keys.sort.each do |name|
346
+ labels = labels_of[name] || {}
347
+ next if labels.size < 2
348
+
349
+ groups = labels.values.map { |v| infer_type(v) }.reject { |t| t == "empty" }
350
+ .map { |t| type_group(t) }.uniq
351
+ next if groups.size < 2
352
+
353
+ errors << Finding.new("type-mismatch", "error", name,
354
+ "inferred type differs across environments", defined[name])
355
+ end
356
+
357
+ # --- errors: schema-validation ---
358
+ load_schema(root).sort.each do |name, rule|
359
+ next unless rule.is_a?(Hash)
360
+
361
+ if defined.key?(name)
362
+ msg = schema_failure(rule, defined_value[name])
363
+ errors << Finding.new("schema-validation", "error", name, msg, defined[name]) if msg
364
+ elsif !rule["optional"]
365
+ errors << Finding.new("schema-validation", "error", name,
366
+ "required by schema but not defined", nil)
367
+ end
368
+ end
369
+
370
+ # --- warnings: unused ---
85
371
  defined.keys.sort.each do |name|
86
372
  next if used.key?(name)
87
373
 
88
- findings << Finding.new("unused", "warning", name,
374
+ warnings << Finding.new("unused", "warning", name,
89
375
  "defined but never referenced in source", defined[name])
90
376
  end
91
- findings
377
+
378
+ # --- warnings: environment-diff ---
379
+ if project_labels.length >= 2
380
+ defined.keys.sort.each do |name|
381
+ present = (labels_of[name] || {}).keys.sort
382
+ absent = (project_labels - present).sort
383
+ next if present.empty? || absent.empty?
384
+
385
+ warnings << Finding.new("environment-diff", "warning", name,
386
+ "defined in #{present.join(', ')} but missing in " \
387
+ "#{absent.join(', ')}", defined[name])
388
+ end
389
+ end
390
+
391
+ # --- warnings: weak-secret ---
392
+ defined.keys.sort.each do |name|
393
+ next unless SECRET_RE.match?(name)
394
+ next unless weak_secret?(defined_value[name].to_s)
395
+
396
+ warnings << Finding.new("weak-secret", "warning", name,
397
+ "secret-looking variable has a weak or placeholder value",
398
+ defined[name])
399
+ end
400
+
401
+ # --- warnings: typo ---
402
+ defined_names = defined.keys
403
+ used.keys.sort.each do |u|
404
+ next if defined.key?(u)
405
+
406
+ best = nil
407
+ best_dist = nil
408
+ defined_names.each do |d|
409
+ next if d == u
410
+
411
+ limit = [u.length, d.length].min <= 4 ? 1 : 2
412
+ dist = levenshtein(u, d)
413
+ next if dist > limit
414
+
415
+ if best.nil? || dist < best_dist || (dist == best_dist && d < best)
416
+ best = d
417
+ best_dist = dist
418
+ end
419
+ end
420
+ next if best.nil?
421
+
422
+ warnings << Finding.new("typo", "warning", u,
423
+ "\"#{u}\" is not defined; did you mean \"#{best}\"?",
424
+ used[u])
425
+ end
426
+
427
+ errors + warnings
428
+ end
429
+
430
+ # Serialize findings to the shared JSON shape. Values never appear.
431
+ NUMERIC_RE = /\A-?\d+(\.\d+)?\z/.freeze
432
+
433
+ def load_schema(root)
434
+ require "json"
435
+ data = JSON.parse(File.read(File.join(root, "envdoctor.schema.json")))
436
+ data.is_a?(Hash) ? data : {}
437
+ rescue StandardError
438
+ {}
439
+ end
440
+
441
+ def schema_type_ok(value, declared)
442
+ case declared
443
+ when "string" then true
444
+ when "integer" then value.match?(/\A-?\d+\z/)
445
+ when "float" then value.match?(/\A-?\d+(\.\d+)?\z/)
446
+ when "boolean" then %w[true false].include?(value.downcase)
447
+ when "url" then value.match?(%r{\Ahttps?://})
448
+ when "json" then (JSON.parse(value); true rescue false)
449
+ else true
450
+ end
451
+ end
452
+
453
+ def schema_failure(rule, value)
454
+ t = rule["type"]
455
+ return "value does not match schema type #{t}" if t.is_a?(String) && !schema_type_ok(value, t)
456
+
457
+ enum = rule["enum"]
458
+ return "value is not one of the allowed values" if enum.is_a?(Array) && !enum.include?(value)
459
+
460
+ pattern = rule["regex"]
461
+ if pattern.is_a?(String)
462
+ begin
463
+ return "value does not match the required pattern" if value !~ Regexp.new(pattern)
464
+ rescue RegexpError
465
+ # ignore invalid pattern
466
+ end
467
+ end
468
+
469
+ if value.match?(NUMERIC_RE)
470
+ num = value.to_f
471
+ lo = rule["min"]
472
+ return "value is below the minimum" if lo.is_a?(Numeric) && num < lo
473
+
474
+ hi = rule["max"]
475
+ return "value exceeds the maximum" if hi.is_a?(Numeric) && num > hi
476
+ end
477
+ nil
478
+ end
479
+
480
+ def to_json_array(findings)
481
+ JSON.generate(findings.map do |f|
482
+ {
483
+ "rule" => f.rule,
484
+ "severity" => f.severity,
485
+ "name" => f.name,
486
+ "message" => f.message,
487
+ "file" => f.origin&.file,
488
+ "line" => f.origin&.line
489
+ }
490
+ end)
92
491
  end
93
492
 
94
493
  def relative(root, path)
data/lib/envdoctor.rb CHANGED
@@ -4,5 +4,5 @@ require_relative "envdoctor/scanner"
4
4
  require_relative "envdoctor/cli"
5
5
 
6
6
  module Envdoctor
7
- VERSION = "0.1.0"
7
+ VERSION = "0.1.2"
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: envdoctor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Arun Natesan
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-22 00:00:00.000000000 Z
11
+ date: 2026-08-23 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: 'Reconciles ENV usage in Ruby source against .env files: reports undefined-in-source
14
14
  (error) and unused (warning). Local-first, no network.'