envdoctor 0.1.1 → 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: c56b394b123626a7937ba9451db0598b9825f3a83d93a8216e973b0c067b8d8a
4
- data.tar.gz: 67da28da5cdd7a8bf767435c77251f031dda10c4dd727f867c5d70f5eca871fe
3
+ metadata.gz: 2d2670c59c370dc3e03b3f3e91a28285d064670f6551093d6775eb235ec2b6bb
4
+ data.tar.gz: 82c7e7a3c9fa5bb3c171cb78cbd90c646a54f699d8063afdf8ce847f4e593082
5
5
  SHA512:
6
- metadata.gz: 14f9554eedeb73d00fd139a20d24c7491470c56acce2902c3f2fa27039069d918807776bdd85198b479ee88a3ea178e47a9db210f3b86b9e645211ad013fddf0
7
- data.tar.gz: 0ee4ca3cf10a6e51b323f8d57ff7babf7b1a6b9050ff1ea6657907129bc66eed67c51a5b874cdda180d9cfafad592ec8e74c825b8c789188916db40e7a9cc28f
6
+ metadata.gz: 9a800d6307b081f2509adf2a262de63c5bae54d0b29a441ffb8dfc6daa746097e94410126e255cdaf2c94b8f8aa10667a6955bf4f3ca85284bd6bf0b19cd6ba1
7
+ data.tar.gz: fc5b091e8345c4da7c7fe02dfa5df00319ef12b8e437b9f910733dadb375e7ab6061773adaed398c09df120f3b27fd87891cec31967503021fcab6322f919fbe
data/README.md CHANGED
@@ -20,11 +20,15 @@ envdoctor scan --json # emit findings as a JSON array (values never incl
20
20
  ## What it detects
21
21
 
22
22
  Reconciles variables **used** in Ruby source (`ENV["X"]`, `ENV['X']`,
23
- `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:
24
28
 
25
29
  | Rule | Severity | Meaning |
26
30
  |------|----------|---------|
27
- | `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 |
28
32
  | `duplicates` | error | Same key defined 2+ times in a single `.env` file |
29
33
  | `public-prefix` | error | Secret-looking variable exposed to client bundles via a public prefix (`NEXT_PUBLIC_`, `VITE_`, `REACT_APP_`, …) |
30
34
  | `type-mismatch` | error | Variable's inferred value type differs across environments (e.g. integer vs string) |
@@ -51,6 +55,43 @@ ruby -Ilib test/test_scanner.rb
51
55
  gem build envdoctor.gemspec
52
56
  ```
53
57
 
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
+
54
95
  ## Other languages
55
96
 
56
97
  envdoctor ships as a standalone native port for each ecosystem:
data/lib/envdoctor/cli.rb CHANGED
@@ -9,6 +9,11 @@ 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
14
19
  json = false
@@ -39,17 +44,118 @@ module Envdoctor
39
44
  return 0
40
45
  end
41
46
 
47
+ loc = ->(f) { f.origin ? " #{f.origin.file}:#{f.origin.line}" : "" }
42
48
  unless errors.empty?
43
49
  puts "\nErrors"
44
- 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}" }
45
51
  end
46
52
  unless warnings.empty?
47
53
  puts "\nWarnings"
48
- 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}" }
49
55
  end
50
56
  puts "\nSummary: #{errors.length} error(s), #{warnings.length} warning(s)"
51
57
 
52
58
  (!errors.empty? || (strict && !warnings.empty?)) ? 1 : 0
53
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
54
160
  end
55
161
  end
@@ -149,6 +149,135 @@ module Envdoctor
149
149
  end.sort
150
150
  end
151
151
 
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)
247
+ defined = {}
248
+ discover_env_files(root).each do |f|
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
+
152
281
  def scan(root)
153
282
  defined = {} # name => Origin (first definition wins)
154
283
  defined_value = {} # name => value at first definition
@@ -182,6 +311,11 @@ module Envdoctor
182
311
  discover_source_files(root).each do |f|
183
312
  scan_source(relative(root, f), File.read(f)).each { |k, v| used[k] ||= v }
184
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
185
319
 
186
320
  errors = []
187
321
  warnings = []
@@ -191,7 +325,7 @@ module Envdoctor
191
325
  next if defined.key?(name)
192
326
 
193
327
  errors << Finding.new("undefined-in-source", "error", name,
194
- "used in source code but not defined in any environment file",
328
+ "referenced but not defined in any environment file",
195
329
  used[name])
196
330
  end
197
331
 
@@ -220,6 +354,19 @@ module Envdoctor
220
354
  "inferred type differs across environments", defined[name])
221
355
  end
222
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
+
223
370
  # --- warnings: unused ---
224
371
  defined.keys.sort.each do |name|
225
372
  next if used.key?(name)
@@ -281,6 +428,55 @@ module Envdoctor
281
428
  end
282
429
 
283
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
+
284
480
  def to_json_array(findings)
285
481
  JSON.generate(findings.map do |f|
286
482
  {
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.1"
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.1
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.'