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 +4 -4
- data/README.md +43 -2
- data/lib/envdoctor/cli.rb +108 -2
- data/lib/envdoctor/scanner.rb +197 -1
- data/lib/envdoctor.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2d2670c59c370dc3e03b3f3e91a28285d064670f6551093d6775eb235ec2b6bb
|
|
4
|
+
data.tar.gz: 82c7e7a3c9fa5bb3c171cb78cbd90c646a54f699d8063afdf8ce847f4e593082
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 |
|
|
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}
|
|
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}
|
|
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
|
data/lib/envdoctor/scanner.rb
CHANGED
|
@@ -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
|
-
"
|
|
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
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.
|
|
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-
|
|
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.'
|