envspec 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: 28f13d06ae0882152222605d7b631f76f181c63fd91544ace2e53c33cc718ffa
4
+ data.tar.gz: 616d302174e177c0cce5864a4a12533710ea4823c528496a0e88a9f1466462be
5
+ SHA512:
6
+ metadata.gz: 9b57dba76d1e5862c97548c1c0b040e1147ce3abac04e3034d9ae313f46c27fbbdb9a3105d776a7da2d5d066412a05373dfc19c769698222c25406f9d9b651d0
7
+ data.tar.gz: a19532006f8fc6822fa3a8b4ed7a17f4ba6485547233819e76c762df9a22b79b09a6ab52adf36401d760bc0ae0265939ad9e848d967ccb0f349b0572ca0db65d
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-05-04
4
+
5
+ Initial release.
6
+
7
+ - Parser for `env.spec` syntax v5 (`[secrets]` modifier, `[env: …]` selector, scoped configmap/secrets, types `str | int | bool | dsn | enum`).
8
+ - Validator: presence + type-level checks (int/bool/enum/dsn).
9
+ - CLI: `envspec lint`, `envspec check`, `envspec init`.
10
+ - `init` scans Ruby/Python/JS/Go/Shell sources and generates a starter `env.spec` with heuristic-based suggestions.
11
+ - Zero runtime dependencies; Ruby ≥ 2.5.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leadfy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # envspec
2
+
3
+ Declarative env var contract for apps. Pure-Ruby gem with **zero runtime dependencies**.
4
+
5
+ `envspec` parses `env.spec` files — a small DSL that describes which env vars an app expects, their types, optionality, and ConfigMap-vs-Secret classification. Use it as a CI lint, a Rails boot check, or a deploy-time contract.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ gem install envspec
11
+ ```
12
+
13
+ Requires Ruby ≥ 2.5. No native extensions, no other gems.
14
+
15
+ ## Quick start
16
+
17
+ ```sh
18
+ # Generate env.spec from a scan of your repo
19
+ envspec init
20
+
21
+ # Validate the spec syntactically
22
+ envspec lint env.spec
23
+
24
+ # Check current ENV against the spec
25
+ envspec check env.spec --env=production
26
+ ```
27
+
28
+ ## env.spec syntax
29
+
30
+ ```
31
+ # shared (all envs)
32
+ APP_NAME
33
+ LOG_LEVEL: enum(debug, info, warn, error) = info
34
+ DEBUG?: bool
35
+ PORT: int = 3000
36
+
37
+ [secrets]
38
+ DB_PASS
39
+ OPENAI_API_KEY
40
+
41
+ [env: production]
42
+ DB_HOST
43
+ [secrets]
44
+ STRIPE_LIVE_KEY
45
+
46
+ [env: production, staging]
47
+ SENTRY_DSN: dsn
48
+ ```
49
+
50
+ - `KEY` — required string in shared scope, ConfigMap
51
+ - `KEY?` — optional
52
+ - `KEY: type` — typed (`str | int | bool | dsn | enum(a,b,c)`)
53
+ - `KEY = default` — default value (configmap only; secrets cannot have defaults)
54
+ - `[secrets]` — modifier; subsequent keys become Secrets (scope unchanged)
55
+ - `[env: name]` / `[env: a, b]` — selector; subsequent keys scoped to those envs (also resets type back to ConfigMap — explicit beats implicit)
56
+
57
+ ## Library API
58
+
59
+ ```ruby
60
+ require "envspec"
61
+
62
+ spec = EnvSpec.parse_file("env.spec")
63
+
64
+ spec.configmap # => { "*" => {...}, "production" => {...} }
65
+ spec.secrets # => { "*" => {...}, "production" => {...} }
66
+ spec.envs # => ["production", "staging"]
67
+ spec.configmap_for("production") # merged shared + production
68
+
69
+ spec.validate!(ENV.to_h, env: "production") # raises EnvSpec::ValidationError if anything missing or wrong type
70
+ ```
71
+
72
+ ## License
73
+
74
+ MIT.
data/exe/envspec ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env ruby
2
+ require "optparse"
3
+ require "envspec"
4
+
5
+ def die(msg, code = 1)
6
+ warn "envspec: #{msg}"
7
+ exit code
8
+ end
9
+
10
+ def usage
11
+ <<~USAGE
12
+ Usage: envspec <command> [options]
13
+
14
+ Commands:
15
+ lint <file> Parse and validate syntax of an env.spec file
16
+ check <file> [--env=NAME] Check current ENV against the spec
17
+ (presence + type validation)
18
+ init [--force] [--output=PATH]
19
+ Scan repo for env var usages and generate env.spec
20
+
21
+ Options:
22
+ -h, --help Show this help
23
+ -v, --version Show version
24
+
25
+ USAGE
26
+ end
27
+
28
+ cmd = ARGV.shift
29
+
30
+ case cmd
31
+ when nil, "-h", "--help", "help"
32
+ puts usage
33
+ exit 0
34
+
35
+ when "-v", "--version", "version"
36
+ puts EnvSpec::VERSION
37
+ exit 0
38
+
39
+ when "lint"
40
+ file = ARGV.shift or die "lint: missing file argument\n\n#{usage}"
41
+ die "file not found: #{file}" unless File.exist?(file)
42
+ begin
43
+ EnvSpec.parse_file(file)
44
+ puts "✓ #{file} is valid"
45
+ rescue EnvSpec::ParseError => e
46
+ die e.message
47
+ end
48
+
49
+ when "check"
50
+ file = nil
51
+ env = "*"
52
+ strict = false
53
+
54
+ parser = OptionParser.new do |o|
55
+ o.on("--env=NAME") { |v| env = v }
56
+ o.on("--strict") { strict = true }
57
+ end
58
+ rest = parser.parse(ARGV)
59
+ file = rest.shift or die "check: missing file argument\n\n#{usage}"
60
+ die "file not found: #{file}" unless File.exist?(file)
61
+
62
+ spec = begin
63
+ EnvSpec.parse_file(file)
64
+ rescue EnvSpec::ParseError => e
65
+ die e.message
66
+ end
67
+
68
+ begin
69
+ spec.validate!(ENV.to_h, env: env, strict: strict)
70
+ scope_label = env == "*" ? "shared" : env
71
+ puts "✓ ENV satisfies #{file} (env: #{scope_label})"
72
+ rescue EnvSpec::ValidationError => e
73
+ die e.message
74
+ end
75
+
76
+ when "init"
77
+ force = false
78
+ output = "env.spec"
79
+ root = Dir.pwd
80
+
81
+ parser = OptionParser.new do |o|
82
+ o.on("--force") { force = true }
83
+ o.on("--output=PATH") { |v| output = v }
84
+ o.on("--root=PATH") { |v| root = v }
85
+ end
86
+ parser.parse(ARGV)
87
+
88
+ result = EnvSpec::Init.run(root: root, force: force, output: output)
89
+
90
+ unless result[:ok]
91
+ die "#{output} already exists (use --force to overwrite)" if result[:reason] == :exists
92
+ die "init failed: #{result[:reason]}"
93
+ end
94
+
95
+ puts "✓ Scanned in #{format('%.2fs', result[:elapsed])}"
96
+ puts "✓ Found #{result[:keys]} env vars across #{result[:files]} files"
97
+ puts "✓ Wrote #{result[:path]}"
98
+ puts ""
99
+ puts "Next steps:"
100
+ puts " 1. Edit #{output} — review heuristic suggestions, classify secrets"
101
+ puts " 2. Run `envspec lint #{output}` to validate"
102
+ puts " 3. Run `envspec check #{output}` to test against your current ENV"
103
+
104
+ else
105
+ die "unknown command: #{cmd}\n\n#{usage}"
106
+ end
@@ -0,0 +1,3 @@
1
+ class EnvSpec
2
+ Entry = Struct.new(:type, :optional, :default, :enum_values, :line, keyword_init: true)
3
+ end
@@ -0,0 +1,20 @@
1
+ class EnvSpec
2
+ class Error < StandardError; end
3
+
4
+ class ParseError < Error
5
+ attr_reader :line_no
6
+ def initialize(msg, line_no = nil)
7
+ super(line_no ? "line #{line_no}: #{msg}" : msg)
8
+ @line_no = line_no
9
+ end
10
+ end
11
+
12
+ class ValidationError < Error
13
+ attr_reader :problems
14
+ def initialize(problems)
15
+ @problems = Array(problems)
16
+ lines = ["env validation failed:"] + @problems.map { |p| " - #{p}" }
17
+ super(lines.join("\n"))
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,38 @@
1
+ class EnvSpec
2
+ # Best-effort, conservative inference of envspec syntax from a key name and
3
+ # observed usage data. Output is ALWAYS suggestive — the generated env.spec
4
+ # file marks heuristic decisions as TODO comments so the dev reviews them.
5
+ module Heuristics
6
+ SECRET_NAME_RE = /(?:KEY|SECRET|TOKEN|PASSWORD|PASS|CREDENTIAL|PRIVATE)\b/.freeze
7
+ DSN_NAME_RE = /(?:_URL|_URI|_DSN|DATABASE_URL|REDIS_URL|MONGO_URL|AMQP_URL)\z/.freeze
8
+ INT_NAME_RE = /(?:PORT|TIMEOUT|INTERVAL|SIZE|COUNT|LIMIT|MAX_|MIN_)/.freeze
9
+ BOOL_NAME_RE = /\A(?:DEBUG|VERBOSE|ENABLE_|.*_ENABLED|IS_|HAS_|USE_)/.freeze
10
+
11
+ def self.infer(name, usages)
12
+ {
13
+ secret: secret?(name),
14
+ type: infer_type(name),
15
+ optional: usages.any? { |u| u[:optional] },
16
+ default: pick_default(usages),
17
+ }
18
+ end
19
+
20
+ def self.secret?(name)
21
+ return true if name =~ SECRET_NAME_RE
22
+ return true if name =~ DSN_NAME_RE
23
+ false
24
+ end
25
+
26
+ def self.infer_type(name)
27
+ return :dsn if name =~ DSN_NAME_RE
28
+ return :int if name =~ INT_NAME_RE
29
+ return :bool if name =~ BOOL_NAME_RE
30
+ :str
31
+ end
32
+
33
+ def self.pick_default(usages)
34
+ defaults = usages.map { |u| u[:default] }.compact.uniq
35
+ defaults.first
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,103 @@
1
+ require_relative "scanner"
2
+ require_relative "heuristics"
3
+
4
+ class EnvSpec
5
+ # Generates an initial env.spec by scanning a repo for env var usages.
6
+ module Init
7
+ HEADER = <<~HEADER
8
+ # env.spec — generated by `envspec init` %s
9
+ #
10
+ # This is a starting point. Review and adjust:
11
+ # 1. Move secrets (passwords, tokens, keys) under [secrets]
12
+ # 2. Add types: str (default) | int | bool | dsn | enum(a,b,c)
13
+ # 3. Mark optional vars with ?
14
+ # 4. Add defaults for configmap vars: KEY: type = value
15
+ # 5. Group per-env vars under [env: production], [env: staging], etc
16
+ #
17
+ # Discovered usages are listed as comments above each key.
18
+ # Confirm or remove before committing.
19
+ #
20
+ # Validate: envspec lint env.spec
21
+ # See docs: https://github.com/repleadfy/envspec
22
+ HEADER
23
+
24
+ def self.run(root: Dir.pwd, force: false, output: "env.spec")
25
+ out_path = File.expand_path(output, root)
26
+ if File.exist?(out_path) && !force
27
+ return { ok: false, reason: :exists, path: out_path }
28
+ end
29
+
30
+ t0 = Time.now
31
+ results = Scanner.scan(root)
32
+ elapsed = Time.now - t0
33
+
34
+ content = render(results, root)
35
+ File.write(out_path, content)
36
+
37
+ {
38
+ ok: true,
39
+ path: out_path,
40
+ keys: results.size,
41
+ files: results.values.flatten.map { |u| u[:file] }.uniq.size,
42
+ elapsed: elapsed,
43
+ }
44
+ end
45
+
46
+ def self.render(results, root)
47
+ out = []
48
+ out << format(HEADER, Time.now.strftime("%Y-%m-%d"))
49
+ out << ""
50
+
51
+ configmap_keys = []
52
+ secret_keys = []
53
+
54
+ results.keys.sort.each do |name|
55
+ usages = results[name]
56
+ inference = Heuristics.infer(name, usages)
57
+ block = render_key(name, usages, inference, root)
58
+ if inference[:secret]
59
+ secret_keys << block
60
+ else
61
+ configmap_keys << block
62
+ end
63
+ end
64
+
65
+ out.concat(configmap_keys) unless configmap_keys.empty?
66
+
67
+ unless secret_keys.empty?
68
+ out << ""
69
+ out << "[secrets]"
70
+ out.concat(secret_keys)
71
+ end
72
+
73
+ out.join("\n").gsub(/\n{3,}/, "\n\n").strip + "\n"
74
+ end
75
+
76
+ def self.render_key(name, usages, inference, root)
77
+ lines = []
78
+
79
+ usages.first(3).each do |u|
80
+ rel = u[:file].sub(/\A#{Regexp.escape(root)}\/?/, "")
81
+ lines << "# Found in: #{rel}:#{u[:line]}"
82
+ end
83
+ lines << "# (and #{usages.size - 3} more usages)" if usages.size > 3
84
+
85
+ hints = []
86
+ hints << "name suggests #{inference[:type]}" if inference[:type] != :str
87
+ hints << "name suggests secret" if inference[:secret] && Heuristics::SECRET_NAME_RE.match?(name) && inference[:type] != :dsn
88
+ hints << "uses fetch with default" if inference[:optional] && inference[:default]
89
+ lines << "# Heuristic: #{hints.join(', ')}" unless hints.empty?
90
+
91
+ decl = name.dup
92
+ decl << "?" if inference[:optional]
93
+
94
+ type = inference[:type]
95
+ decl << ": #{type}" if type != :str || (inference[:default] && type == :str)
96
+ decl << " = #{inference[:default]}" if inference[:default] && !inference[:secret]
97
+
98
+ lines << decl
99
+ lines << ""
100
+ lines.join("\n")
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,186 @@
1
+ # Parses env.spec files -- declarative contract for ConfigMap and Secret keys
2
+ # an app expects.
3
+ #
4
+ # Syntax (v5):
5
+ # KEY # str, required, configmap, shared (all envs)
6
+ # KEY? # optional
7
+ # KEY: int # typed (str|int|bool|dsn|enum(a,b))
8
+ # KEY: str = default # with default (configmap only)
9
+ #
10
+ # [secrets] # MODIFIER: type flips to secret (scope unchanged)
11
+ # [env: production] # SELECTOR: scope -> production, type RESETS to configmap
12
+ # [env: production, staging]# multi-env scope
13
+ #
14
+ # Output:
15
+ # configmap = { "*" => { "KEY" => Entry }, "production" => {...} }
16
+ # secrets = { "*" => { "DB_PASS" => Entry }, "production" => {...} }
17
+ class EnvSpec
18
+ SHARED = "*".freeze
19
+ VALID_TYPES = %i[str int bool dsn enum].freeze
20
+ ENV_NAME_RE = /\A[A-Za-z0-9_-]+\z/.freeze
21
+
22
+ attr_reader :configmap, :secrets
23
+
24
+ def self.parse(text)
25
+ new.tap { |spec| spec.send(:_parse, text) }
26
+ end
27
+
28
+ def self.parse_file(path)
29
+ parse(File.read(path))
30
+ end
31
+
32
+ def initialize
33
+ @configmap = {}
34
+ @secrets = {}
35
+ end
36
+
37
+ # Returns array of env names declared in the spec (excluding "*").
38
+ def envs
39
+ (@configmap.keys + @secrets.keys).uniq.reject { |e| e == SHARED }.sort
40
+ end
41
+
42
+ # Returns merged keys (shared + given env) for configmap.
43
+ def configmap_for(env)
44
+ merge_scope(@configmap, env)
45
+ end
46
+
47
+ def secrets_for(env)
48
+ merge_scope(@secrets, env)
49
+ end
50
+
51
+ private
52
+
53
+ def merge_scope(bucket, env)
54
+ out = {}
55
+ out.merge!(bucket[SHARED]) if bucket[SHARED]
56
+ out.merge!(bucket[env]) if bucket[env]
57
+ out
58
+ end
59
+
60
+ def _parse(text)
61
+ scope = [SHARED]
62
+ type = :configmap
63
+
64
+ text.each_line.with_index(1) do |raw, line_no|
65
+ line = raw.sub(/#.*/, "").strip
66
+ next if line.empty?
67
+
68
+ if line.start_with?("[") && line.end_with?("]")
69
+ inner = line[1..-2].strip
70
+ if inner == "secrets"
71
+ type = :secret
72
+ elsif inner.start_with?("env:")
73
+ envs = inner.sub(/\Aenv:/, "").split(",").map(&:strip).reject(&:empty?)
74
+ raise ParseError.new("empty [env: ...] selector", line_no) if envs.empty?
75
+ envs.each do |e|
76
+ unless ENV_NAME_RE.match?(e)
77
+ raise ParseError.new(
78
+ "invalid env name '#{e}' in [env: ...] (must match #{ENV_NAME_RE.source}; use commas to separate envs)",
79
+ line_no
80
+ )
81
+ end
82
+ end
83
+ scope = envs
84
+ type = :configmap
85
+ else
86
+ raise ParseError.new("unknown header [#{inner}] (only [secrets] and [env: ...] allowed)", line_no)
87
+ end
88
+ next
89
+ end
90
+
91
+ entry = parse_key_line(line, line_no)
92
+ if type == :secret && !entry[:default].nil?
93
+ raise ParseError.new("secret '#{entry[:name]}' cannot have a default value", line_no)
94
+ end
95
+
96
+ store_entry(type, scope, entry, line_no)
97
+ end
98
+ end
99
+
100
+ def parse_key_line(line, line_no)
101
+ m = line.match(/\A([A-Z][A-Z0-9_]*)(\?)?(?:\s*:\s*([a-z]+(?:\([^)]*\))?))?(?:\s*=\s*(.+))?\z/)
102
+ raise ParseError.new("invalid key line: #{line.inspect}", line_no) unless m
103
+
104
+ name = m[1]
105
+ optional = !m[2].nil?
106
+ type_str = m[3] || "str"
107
+ default = m[4] && m[4].strip
108
+
109
+ type, enum_values = parse_type(type_str, line_no)
110
+ { name: name, type: type, optional: optional, default: default, enum_values: enum_values }
111
+ end
112
+
113
+ def parse_type(type_str, line_no)
114
+ if type_str.start_with?("enum(")
115
+ unless (em = type_str.match(/\Aenum\((.+)\)\z/))
116
+ raise ParseError.new("malformed enum type: #{type_str}", line_no)
117
+ end
118
+ values = em[1].split(",").map(&:strip).reject(&:empty?)
119
+ raise ParseError.new("enum requires at least one value", line_no) if values.empty?
120
+ [:enum, values]
121
+ else
122
+ sym = type_str.to_sym
123
+ unless VALID_TYPES.include?(sym)
124
+ raise ParseError.new("unknown type '#{type_str}' (valid: #{VALID_TYPES.join(', ')})", line_no)
125
+ end
126
+ [sym, nil]
127
+ end
128
+ end
129
+
130
+ def store_entry(type, scope, e, line_no)
131
+ bucket = (type == :secret ? @secrets : @configmap)
132
+
133
+ scope.each do |env|
134
+ bucket[env] ||= {}
135
+
136
+ if (existing = find_overlapping(bucket, env, e[:name]))
137
+ raise ParseError.new(
138
+ "key '#{e[:name]}' defined in overlapping scopes ('#{existing[:env]}' line #{existing[:entry].line} and '#{env}' here)",
139
+ line_no
140
+ )
141
+ end
142
+
143
+ bucket[env][e[:name]] = Entry.new(
144
+ type: e[:type],
145
+ optional: e[:optional],
146
+ default: e[:default],
147
+ enum_values: e[:enum_values],
148
+ line: line_no
149
+ )
150
+ end
151
+
152
+ check_cross_env_type_consistency(bucket, e[:name], e[:type], e[:enum_values], line_no)
153
+ end
154
+
155
+ def find_overlapping(bucket, env, key)
156
+ bucket.each do |existing_env, keys|
157
+ next unless keys.key?(key)
158
+ next if existing_env == env
159
+ if existing_env == SHARED || env == SHARED
160
+ return { env: existing_env, entry: keys[key] }
161
+ end
162
+ end
163
+ if bucket[env] && bucket[env].key?(key)
164
+ return { env: env, entry: bucket[env][key] }
165
+ end
166
+ nil
167
+ end
168
+
169
+ def check_cross_env_type_consistency(bucket, key, type, enum_values, line_no)
170
+ seen = nil
171
+ bucket.each do |env, keys|
172
+ next unless keys.key?(key)
173
+ e = keys[key]
174
+ if seen.nil?
175
+ seen = [env, e.type, e.enum_values]
176
+ else
177
+ if seen[1] != e.type || seen[2] != e.enum_values
178
+ raise ParseError.new(
179
+ "key '#{key}' has different types across envs ('#{seen[0]}' is #{seen[1]}, '#{env}' is #{e.type})",
180
+ line_no
181
+ )
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,161 @@
1
+ require "find"
2
+ require "set"
3
+
4
+ class EnvSpec
5
+ # Scans a directory tree for env var usages across multiple languages.
6
+ # Pure regex (no AST parser) to keep zero-deps requirement.
7
+ #
8
+ # Returns:
9
+ # { "OPENAI_API_KEY" => [{ file: "...", line: 9, default: nil, optional: true }, ...] }
10
+ module Scanner
11
+ SKIP_DIRS = %w[
12
+ .git node_modules vendor tmp log logs coverage
13
+ .bundle .yarn .pnpm-store dist build target
14
+ __pycache__ .venv venv .pytest_cache .next .nuxt .turbo
15
+ ].to_set.freeze
16
+
17
+ SKIP_FILE_RE = /
18
+ \.lock\z |
19
+ \.min\.(js|css)\z |
20
+ \.svg\z |
21
+ \.png\z | \.jpg\z | \.jpeg\z | \.gif\z | \.ico\z | \.webp\z |
22
+ \.pdf\z | \.zip\z | \.tar\z | \.gz\z |
23
+ \.woff2?\z | \.ttf\z | \.eot\z |
24
+ \.map\z
25
+ /x.freeze
26
+
27
+ MAX_FILE_BYTES = 1_048_576 # 1 MB
28
+
29
+ EXT_LANG = {
30
+ ".rb" => :ruby,
31
+ ".rake" => :ruby,
32
+ ".gemspec"=> :ruby,
33
+ ".py" => :python,
34
+ ".js" => :js,
35
+ ".jsx" => :js,
36
+ ".ts" => :js,
37
+ ".tsx" => :js,
38
+ ".mjs" => :js,
39
+ ".cjs" => :js,
40
+ ".go" => :go,
41
+ ".sh" => :shell,
42
+ ".bash" => :shell,
43
+ }.freeze
44
+
45
+ SPECIAL_FILES = {
46
+ "Dockerfile" => :shell,
47
+ "Rakefile" => :ruby,
48
+ "Gemfile" => :ruby,
49
+ "config.ru" => :ruby,
50
+ }.freeze
51
+
52
+ PATTERNS = {
53
+ ruby: [
54
+ /ENV\s*\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\]/,
55
+ /ENV\.fetch\s*\(\s*["']([A-Z][A-Z0-9_]*)["'](?:\s*,\s*([^)]+))?\s*\)/,
56
+ ],
57
+ python: [
58
+ /os\.environ\s*\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\]/,
59
+ /os\.environ\.get\s*\(\s*["']([A-Z][A-Z0-9_]*)["'](?:\s*,\s*([^)]+))?\s*\)/,
60
+ /os\.getenv\s*\(\s*["']([A-Z][A-Z0-9_]*)["'](?:\s*,\s*([^)]+))?\s*\)/,
61
+ ],
62
+ js: [
63
+ /process\.env\.([A-Z][A-Z0-9_]*)/,
64
+ /process\.env\s*\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\]/,
65
+ /import\.meta\.env\.([A-Z][A-Z0-9_]*)/,
66
+ ],
67
+ go: [
68
+ /os\.Getenv\s*\(\s*["']([A-Z][A-Z0-9_]*)["']\s*\)/,
69
+ /os\.LookupEnv\s*\(\s*["']([A-Z][A-Z0-9_]*)["']\s*\)/,
70
+ ],
71
+ shell: [
72
+ /\$\{([A-Z][A-Z0-9_]*)(?::[-=?+][^}]*)?\}/,
73
+ /\$([A-Z][A-Z0-9_]{2,})\b/, # require ≥ 3 chars to dodge $PATH-style false positives... actually $PATH is fine, but cuts $A
74
+ ],
75
+ }.freeze
76
+
77
+ def self.scan(root, ignore_globs: [])
78
+ results = Hash.new { |h, k| h[k] = [] }
79
+ gitignore_globs = parse_gitignore(File.join(root, ".gitignore"))
80
+ all_globs = (ignore_globs + gitignore_globs).uniq
81
+
82
+ Find.find(root) do |path|
83
+ rel = path.sub(/\A#{Regexp.escape(root)}\/?/, "")
84
+
85
+ if File.directory?(path)
86
+ base = File.basename(path)
87
+ if SKIP_DIRS.include?(base) || ignored?(rel, all_globs, dir: true)
88
+ Find.prune
89
+ end
90
+ next
91
+ end
92
+
93
+ next if File.basename(path).start_with?(".") && File.basename(path) != ".envrc"
94
+ next if path =~ SKIP_FILE_RE
95
+ next if File.size(path) > MAX_FILE_BYTES rescue next
96
+ next if ignored?(rel, all_globs, dir: false)
97
+
98
+ lang = lang_for(path)
99
+ next unless lang
100
+
101
+ scan_file(path, lang, results)
102
+ end
103
+
104
+ results
105
+ end
106
+
107
+ def self.lang_for(path)
108
+ base = File.basename(path)
109
+ return SPECIAL_FILES[base] if SPECIAL_FILES.key?(base)
110
+ EXT_LANG[File.extname(path)]
111
+ end
112
+
113
+ def self.scan_file(path, lang, results)
114
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
115
+ patterns = PATTERNS[lang] || []
116
+
117
+ content.each_line.with_index(1) do |line, line_no|
118
+ patterns.each do |re|
119
+ line.scan(re) do |captures|
120
+ name = captures[0]
121
+ default = captures[1] && captures[1].strip
122
+ results[name] << {
123
+ file: path,
124
+ line: line_no,
125
+ default: extract_default(default),
126
+ optional: !default.nil?,
127
+ }
128
+ end
129
+ end
130
+ end
131
+ rescue ArgumentError, Errno::ENOENT
132
+ # Skip unreadable / binary files
133
+ end
134
+
135
+ def self.extract_default(raw)
136
+ return nil if raw.nil? || raw.empty?
137
+ # strip surrounding quotes if literal string
138
+ m = raw.match(/\A["']([^"']*)["']\z/)
139
+ m ? m[1] : nil
140
+ end
141
+
142
+ # Minimal .gitignore parser — supports literal patterns and globs (no
143
+ # full git semantics, just common cases).
144
+ def self.parse_gitignore(path)
145
+ return [] unless File.exist?(path)
146
+ File.readlines(path).map(&:strip).reject { |l| l.empty? || l.start_with?("#") }
147
+ rescue
148
+ []
149
+ end
150
+
151
+ def self.ignored?(rel, globs, dir:)
152
+ globs.any? do |pat|
153
+ clean = pat.sub(/\A\//, "").sub(/\/\z/, "")
154
+ next false if clean.empty?
155
+ File.fnmatch?(clean, rel, File::FNM_PATHNAME) ||
156
+ File.fnmatch?(clean, File.basename(rel)) ||
157
+ rel.start_with?("#{clean}/")
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,70 @@
1
+ class EnvSpec
2
+ # Validates an env hash (or ENV) against a parsed spec.
3
+ #
4
+ # Checks:
5
+ # - required keys are present
6
+ # - typed values parse cleanly (int, bool, dsn, enum)
7
+ # - reports unknown keys as warnings (extras), not errors
8
+ #
9
+ # Usage:
10
+ # spec.validate!(ENV.to_h, env: "production")
11
+ #
12
+ # Raises EnvSpec::ValidationError with a list of all problems found.
13
+ def validate!(env_hash, env: SHARED, strict: false)
14
+ problems = validate(env_hash, env: env, strict: strict)
15
+ raise ValidationError.new(problems) unless problems.empty?
16
+ true
17
+ end
18
+
19
+ def validate(env_hash, env: SHARED, strict: false)
20
+ problems = []
21
+
22
+ keys = configmap_for(env).merge(secrets_for(env))
23
+
24
+ keys.each do |name, entry|
25
+ raw = env_hash[name]
26
+ if raw.nil? || raw.empty?
27
+ next if entry.optional || !entry.default.nil?
28
+ problems << "missing required env var '#{name}' (declared at line #{entry.line})"
29
+ next
30
+ end
31
+
32
+ err = type_problem(name, raw, entry)
33
+ problems << err if err
34
+ end
35
+
36
+ if strict
37
+ known = keys.keys
38
+ env_hash.each_key do |k|
39
+ next unless k =~ /\A[A-Z][A-Z0-9_]*\z/
40
+ problems << "unknown env var '#{k}' (not declared in spec)" unless known.include?(k)
41
+ end
42
+ end
43
+
44
+ problems
45
+ end
46
+
47
+ private
48
+
49
+ def type_problem(name, raw, entry)
50
+ case entry.type
51
+ when :str, :dsn
52
+ nil
53
+ when :int
54
+ Integer(raw, 10)
55
+ nil
56
+ when :bool
57
+ unless %w[true false 1 0 yes no].include?(raw.downcase)
58
+ return "env var '#{name}' = #{raw.inspect} is not a valid bool (expected true/false/1/0/yes/no)"
59
+ end
60
+ nil
61
+ when :enum
62
+ unless entry.enum_values.include?(raw)
63
+ return "env var '#{name}' = #{raw.inspect} is not in enum(#{entry.enum_values.join(', ')})"
64
+ end
65
+ nil
66
+ end
67
+ rescue ArgumentError
68
+ "env var '#{name}' = #{raw.inspect} is not a valid #{entry.type}"
69
+ end
70
+ end
@@ -0,0 +1,3 @@
1
+ class EnvSpec
2
+ VERSION = "0.1.0".freeze
3
+ end
data/lib/envspec.rb ADDED
@@ -0,0 +1,6 @@
1
+ require_relative "envspec/version"
2
+ require_relative "envspec/errors"
3
+ require_relative "envspec/entry"
4
+ require_relative "envspec/parser"
5
+ require_relative "envspec/validator"
6
+ require_relative "envspec/init"
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: envspec
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Leadfy
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-05-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ description: 'Parses env.spec files: a declarative contract describing which env vars
56
+ an app expects, their types, optionality, and ConfigMap-vs-Secret classification.
57
+ Pure stdlib, zero runtime dependencies.'
58
+ email:
59
+ - dev@leadfy.com
60
+ executables:
61
+ - envspec
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - CHANGELOG.md
66
+ - LICENSE.txt
67
+ - README.md
68
+ - exe/envspec
69
+ - lib/envspec.rb
70
+ - lib/envspec/entry.rb
71
+ - lib/envspec/errors.rb
72
+ - lib/envspec/heuristics.rb
73
+ - lib/envspec/init.rb
74
+ - lib/envspec/parser.rb
75
+ - lib/envspec/scanner.rb
76
+ - lib/envspec/validator.rb
77
+ - lib/envspec/version.rb
78
+ homepage: https://github.com/repleadfy/envspec
79
+ licenses:
80
+ - MIT
81
+ metadata: {}
82
+ post_install_message:
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: 2.5.0
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 3.2.32
98
+ signing_key:
99
+ specification_version: 4
100
+ summary: Declarative env var contract for apps (parser + validator + scaffolder)
101
+ test_files: []