standard_health 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8542efb5b2511d31b4c9b3ef2a10f650ed342c1eeadc4aecaadef1476b82bc59
4
- data.tar.gz: 8996c9e8f67904d842f589e60d880127680afa55365cb3be835260db9d36b3c1
3
+ metadata.gz: 4bc8cdc021df7485cb1443cf6902b6fee31627ad6135d9eef6f284cd5eed47e0
4
+ data.tar.gz: bfd4e8a50e602685430481b85a0ebf604d1e930db71ce47492036d772f4aa13c
5
5
  SHA512:
6
- metadata.gz: b528859586bf219cab6b76902a230f3e660d36e4c108db6489ad1c4643fc19455c868bfa1fbe2b5fdc5291173aa04bfaa516ff322f44cecea41cd3cf5381b474
7
- data.tar.gz: fe062a3ab494d205e3d436037a4ea42fbe3802043a7d8b3ee873ff4d5b6dd1ae7d1a7bd4659a3e4c533d92cfc035fa47d7c582801a418e4ba24b8943163ae5d5
6
+ metadata.gz: 32ec724c45eb9899e4d241d5681d296e1ac5745c3ff1852b5a187dea1fcc2a6b7ff7a922c08f925f0b59189ca8523d11fd034a5743f394186d91c4c1f2d8596d
7
+ data.tar.gz: 4711ceae81d4b9d0c19b440cc62fe8b2677ce569661adf741775ecc7933ee93c68402c8141ca830e76a6a75aa023c3f7759ef4d68a175d38280a2d971e83b964
data/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-04-29
11
+
12
+ ### Added
13
+
14
+ - `if:` and `unless:` Proc predicates on `required` and `recommended`. Evaluated at audit time; when `unless:` returns truthy or `if:` returns falsy the entry is reported with `status: :not_applicable` and a `reason` field. Solves the MyInfo mock-mode case where `MYINFO_PRIVATE_JWKS` is only required outside mock mode.
15
+ - `mode_alias` top-level DSL inside `EnvSpec.define`. Maps a Symbol to an `Array<String>` of `APP_ENVIRONMENT` values. `in:` now accepts a Symbol that is resolved against declared aliases at audit time; an undefined Symbol raises `StandardHealth::EnvSpec::UnknownModeAlias`.
16
+ - `consumed_by:` keyword on `required`/`recommended`. Accepts a String or `Array<String>` pointing at where the env var is read in the host app; flows through to audit rows verbatim.
17
+ - `group "Label" do ... end` block inside `EnvSpec.define`. Tags enclosed entries with a `group` field in their audit rows. Pure metadata for ops UX.
18
+ - New `:not_applicable` value for the `status` field, used when an `if:`/`unless:` predicate suppresses an entry.
19
+
20
+ ### Changed
21
+
22
+ - `EnvSpec` internals refactored: `Entry` struct extended with `consumed_by`, `if_predicate`, `unless_predicate`, and `group`. Backward-compatible — every 0.2.0 spec produces identical audit output (modulo `description` now appearing where it was previously suppressed and `group`/`consumed_by` appearing only when set).
23
+
10
24
  ## [0.2.0] - 2026-04-29
11
25
 
12
26
  ### Added
data/README.md CHANGED
@@ -69,8 +69,10 @@ The DSL has two declarations:
69
69
 
70
70
  Both accept:
71
71
 
72
- - `in: %w[staging production]` — restricts the entry to those `APP_ENVIRONMENT` values; ignored otherwise
72
+ - `in: %w[staging production]` — restricts the entry to those `APP_ENVIRONMENT` values; ignored otherwise. May also be a Symbol resolved via `mode_alias` (see below).
73
73
  - `description: "..."` — surfaced verbatim in the audit JSON
74
+ - `consumed_by: "config/initializers/sentry.rb"` — pointer (or `Array<String>`) to where the value is read; surfaced verbatim
75
+ - `if: -> { ... }` / `unless: -> { ... }` — Proc predicates evaluated at audit time. When `unless:` returns truthy or `if:` returns falsy, the entry is reported with `status: :not_applicable`
74
76
 
75
77
  Audit output (one row per applicable entry):
76
78
 
@@ -83,7 +85,108 @@ Audit output (one row per applicable entry):
83
85
  }
84
86
  ```
85
87
 
86
- Possible `status` values are `ok`, `missing` (required + absent), and `should_set` (recommended + absent).
88
+ Possible `status` values are `ok`, `missing` (required + absent), `should_set` (recommended + absent), and `not_applicable` (suppressed by an `if:`/`unless:` predicate).
89
+
90
+ ### Predicates: `if:` and `unless:`
91
+
92
+ Use predicates when an env var is only meaningful under runtime conditions that aren't expressible as a fixed list of `APP_ENVIRONMENT` values — e.g. when a host app supports a "mock mode" toggle.
93
+
94
+ ```ruby
95
+ required :MYINFO_PRIVATE_JWKS,
96
+ in: %w[production],
97
+ unless: -> { ENV["MYINFO_MOCK_MODE"].present? }
98
+
99
+ recommended :SENTRY_DSN,
100
+ if: -> { ENV["SENTRY_DISABLED"].blank? }
101
+ ```
102
+
103
+ A suppressed entry surfaces as:
104
+
105
+ ```json
106
+ {
107
+ "name": "MYINFO_PRIVATE_JWKS",
108
+ "level": "required",
109
+ "status": "not_applicable",
110
+ "reason": "unless predicate matched",
111
+ "mode": "production"
112
+ }
113
+ ```
114
+
115
+ The `reason` is `"unless predicate matched"` or `"if predicate did not match"`. Both predicates may be combined; the entry only evaluates when `if:` is truthy and `unless:` is falsy.
116
+
117
+ ### Mode aliases: `mode_alias`
118
+
119
+ Declare reusable groupings of `APP_ENVIRONMENT` values inside the `define` block, then reference them as Symbols in `in:`. Common patterns ship as conventions (not built-ins): `:deployed` for staging-and-up, `:live` for production-only.
120
+
121
+ ```ruby
122
+ StandardHealth::EnvSpec.define do
123
+ mode_alias :deployed, %w[staging preview production]
124
+ mode_alias :live, %w[production]
125
+
126
+ required :APP_ENVIRONMENT
127
+ required :SENTRY_DSN, in: :deployed
128
+ required :STRIPE_LIVE_KEY, in: :live
129
+ end
130
+ ```
131
+
132
+ `in:` accepts:
133
+
134
+ - `nil` (omitted) — entry always applies
135
+ - `Array<String>` — literal mode list (existing behaviour)
136
+ - `Symbol` — resolved against `mode_alias` at audit time. An undeclared Symbol raises `StandardHealth::EnvSpec::UnknownModeAlias`.
137
+
138
+ ### `description:` and `consumed_by:`
139
+
140
+ Both flow through to audit rows verbatim. `description:` is a human hint; `consumed_by:` points at the file(s) that read the value, which makes "what does this env var actually do" much faster to answer in incident response.
141
+
142
+ ```ruby
143
+ required :APP_HOST,
144
+ in: :deployed,
145
+ description: "Canonical web host",
146
+ consumed_by: "config/initializers/sentry.rb"
147
+ ```
148
+
149
+ ```json
150
+ {
151
+ "name": "APP_HOST",
152
+ "level": "required",
153
+ "status": "ok",
154
+ "mode": "production",
155
+ "description": "Canonical web host",
156
+ "consumed_by": "config/initializers/sentry.rb"
157
+ }
158
+ ```
159
+
160
+ `consumed_by:` may be a String or `Array<String>`; an Array is preserved as a JSON array.
161
+
162
+ ### Groups
163
+
164
+ Wrap related declarations in a `group "Label" do ... end` block to tag them with a category. Groups are pure metadata — they don't affect applicability, status, or evaluation order. Nested `group` blocks are supported; the innermost label propagates to enclosed entries. Calling `group` without a block raises `ArgumentError`.
165
+
166
+ ```ruby
167
+ StandardHealth::EnvSpec.define do
168
+ group "Singpass / MyInfo" do
169
+ required :MYINFO_CLIENT_ID
170
+ required :MYINFO_PRIVATE_JWKS, unless: -> { ENV["MYINFO_MOCK_MODE"].present? }
171
+ end
172
+
173
+ group "Database" do
174
+ required :DATABASE_URL, in: :deployed
175
+ end
176
+ end
177
+ ```
178
+
179
+ Audit rows for entries declared inside a `group` block carry a `group` key:
180
+
181
+ ```json
182
+ { "name": "MYINFO_CLIENT_ID", "level": "required", "status": "ok", "mode": "production", "group": "Singpass / MyInfo" }
183
+ ```
184
+
185
+ Entries declared outside any `group` block omit the `group` key entirely.
186
+
187
+ ### Backward compatibility
188
+
189
+ All v0.2.0 specs continue to produce identical audit output in v0.3.0. The new fields (`description`, `consumed_by`, `group`, `reason`) appear only when the corresponding feature is used; the new `:not_applicable` status only appears when a predicate suppresses an entry.
87
190
 
88
191
  ## Custom checks
89
192
 
@@ -6,32 +6,62 @@ module StandardHealth
6
6
  # Example:
7
7
  #
8
8
  # StandardHealth::EnvSpec.define do
9
+ # mode_alias :deployed, %w[staging preview production]
10
+ # mode_alias :live, %w[production]
11
+ #
9
12
  # required :SECRET_KEY_BASE
10
13
  # required :APP_ENVIRONMENT, in: %w[staging production]
11
14
  # recommended :SENTRY_DSN, description: "Error tracking DSN"
15
+ #
16
+ # group "Singpass / MyInfo" do
17
+ # required :MYINFO_CLIENT_ID
18
+ # required :MYINFO_PRIVATE_JWKS,
19
+ # in: :live,
20
+ # unless: -> { ENV["MYINFO_MOCK_MODE"].present? }
21
+ # end
12
22
  # end
13
23
  #
14
24
  # Each entry has:
15
25
  # - `name` (Symbol)
16
26
  # - `level` (:required | :recommended)
17
- # - `modes` (Array<String>, optional) — when set, the entry only applies
18
- # while `APP_ENVIRONMENT` matches one of these modes. Otherwise it
19
- # applies to every mode.
20
- # - `description` (String, optional) human-readable hint surfaced
27
+ # - `in:` (Array<String>, Symbol, or nil) — when an Array, the entry only
28
+ # applies while `APP_ENVIRONMENT` matches one of these modes. A Symbol
29
+ # is resolved against `mode_alias` declarations at audit time. When
30
+ # omitted, the entry applies to every mode.
31
+ # - `description:` (String, optional) — human-readable hint surfaced
21
32
  # verbatim by `/diagnostics/env`.
33
+ # - `consumed_by:` (String or Array<String>, optional) — pointer(s) to
34
+ # where the value is read in the host app. Surfaced verbatim.
35
+ # - `if:` / `unless:` (Proc, optional) — predicates evaluated at audit
36
+ # time. When `unless:` returns truthy or `if:` returns falsy the entry
37
+ # is reported with `status: :not_applicable`.
38
+ # - `group` (String, optional) — set implicitly by enclosing `group`
39
+ # block; surfaced verbatim.
22
40
  class EnvSpec
23
- Entry = Struct.new(:name, :level, :modes, :description, keyword_init: true) do
24
- # Whether this entry's audit applies under the given runtime mode.
25
- # Entries without a `modes:` constraint always apply.
26
- def applies_to?(mode)
27
- return true if modes.nil? || modes.empty?
28
- modes.include?(mode.to_s)
29
- end
30
- end
41
+ # Raised when `in:` references a Symbol that hasn't been declared via
42
+ # `mode_alias`.
43
+ class UnknownModeAlias < ArgumentError; end
44
+
45
+ # Internal entry record. `modes` may be an Array<String> or a Symbol
46
+ # alias that gets resolved at audit time.
47
+ Entry = Struct.new(
48
+ :name,
49
+ :level,
50
+ :modes,
51
+ :description,
52
+ :consumed_by,
53
+ :if_predicate,
54
+ :unless_predicate,
55
+ :group,
56
+ keyword_init: true
57
+ )
31
58
 
32
59
  # @return [Array<Entry>]
33
60
  attr_reader :entries
34
61
 
62
+ # @return [Hash{Symbol => Array<String>}]
63
+ attr_reader :mode_aliases
64
+
35
65
  # Build a spec via the DSL.
36
66
  def self.define(&block)
37
67
  new.tap { |spec| spec.instance_eval(&block) if block }
@@ -39,13 +69,42 @@ module StandardHealth
39
69
 
40
70
  def initialize
41
71
  @entries = []
72
+ @mode_aliases = {}
73
+ @group_stack = []
74
+ end
75
+
76
+ # Declare a mode alias usable in `in:`. Re-declaring overrides the
77
+ # previous value (last writer wins) so layered specs are easy to compose.
78
+ #
79
+ # @param name [Symbol]
80
+ # @param modes [Array<String>]
81
+ def mode_alias(name, modes)
82
+ @mode_aliases[name.to_sym] = Array(modes).map(&:to_s)
83
+ end
84
+
85
+ # Group subsequent declarations under a label. Pure metadata —
86
+ # propagated to audit rows as `group:` and otherwise inert. Nested
87
+ # `group` blocks are supported; the innermost label is the one that
88
+ # propagates to enclosed entries.
89
+ #
90
+ # @param label [String]
91
+ def group(label, &block)
92
+ raise ArgumentError, "group requires a block" unless block
93
+
94
+ @group_stack.push(label.to_s)
95
+ block.call
96
+ ensure
97
+ @group_stack.pop
42
98
  end
43
99
 
44
100
  # Declare a required env var.
45
101
  #
46
102
  # @param name [Symbol, String]
47
- # @param in [Array<String>, nil] limit applicability to these modes
103
+ # @param in [Array<String>, Symbol, nil] limit applicability to these modes
48
104
  # @param description [String, nil]
105
+ # @param consumed_by [String, Array<String>, nil]
106
+ # @param if [Proc, nil]
107
+ # @param unless [Proc, nil]
49
108
  def required(name, **opts)
50
109
  add(:required, name, **opts)
51
110
  end
@@ -60,41 +119,105 @@ module StandardHealth
60
119
  #
61
120
  # @param env_hash [Hash{String, Symbol => String}] e.g. ENV.to_h
62
121
  # @param mode [String, Symbol] current APP_ENVIRONMENT value
63
- # @return [Array<Hash>] one row per applicable entry, each shaped like:
64
- # { name:, level:, status: :ok | :missing | :should_set, mode: }
122
+ # @return [Array<Hash>] one row per applicable entry. Each row has at
123
+ # least `name`, `level`, `status`, `mode`. When an entry is suppressed
124
+ # by an `if:`/`unless:` predicate, `status` is `:not_applicable` and a
125
+ # `reason` field explains why. `description`, `consumed_by`, and
126
+ # `group` are included when set on the entry.
65
127
  def audit(env_hash, mode:)
66
128
  mode_str = mode.to_s
67
129
  env = stringify(env_hash)
68
130
 
69
131
  @entries.each_with_object([]) do |entry, out|
70
- next unless entry.applies_to?(mode_str)
71
-
72
- value = env[entry.name.to_s]
73
- status = classify(entry, value)
74
-
75
- row = {
76
- name: entry.name,
77
- level: entry.level,
78
- status: status,
79
- mode: mode_str
80
- }
81
- row[:description] = entry.description if entry.description
132
+ next unless mode_applies?(entry, mode_str)
133
+
134
+ row = base_row(entry, mode_str)
135
+
136
+ suppression = predicate_suppression(entry)
137
+ if suppression
138
+ row[:status] = :not_applicable
139
+ row[:reason] = suppression
140
+ else
141
+ value = env[entry.name.to_s]
142
+ row[:status] = classify(entry, value)
143
+ end
144
+
82
145
  out << row
83
146
  end
84
147
  end
85
148
 
86
149
  private
87
150
 
88
- def add(level, name, in: nil, description: nil)
89
- modes = binding.local_variable_get(:in)
151
+ def add(level, name, **opts)
152
+ modes_opt = opts[:in]
90
153
  @entries << Entry.new(
91
154
  name: name.to_sym,
92
155
  level: level,
93
- modes: modes ? Array(modes).map(&:to_s) : nil,
94
- description: description
156
+ modes: normalize_modes(modes_opt),
157
+ description: opts[:description],
158
+ consumed_by: normalize_consumed_by(opts[:consumed_by]),
159
+ if_predicate: opts[:if],
160
+ unless_predicate: opts[:unless],
161
+ group: @group_stack.last
95
162
  )
96
163
  end
97
164
 
165
+ def normalize_modes(modes_opt)
166
+ return nil if modes_opt.nil?
167
+ return modes_opt if modes_opt.is_a?(Symbol)
168
+ Array(modes_opt).map(&:to_s)
169
+ end
170
+
171
+ def normalize_consumed_by(value)
172
+ return nil if value.nil?
173
+ array = Array(value).map(&:to_s)
174
+ array.empty? ? nil : array
175
+ end
176
+
177
+ def mode_applies?(entry, mode_str)
178
+ modes = resolve_modes(entry.modes)
179
+ return true if modes.nil? || modes.empty?
180
+ modes.include?(mode_str)
181
+ end
182
+
183
+ def resolve_modes(modes)
184
+ return nil if modes.nil?
185
+ if modes.is_a?(Symbol)
186
+ unless @mode_aliases.key?(modes)
187
+ raise UnknownModeAlias, "Unknown mode alias: #{modes.inspect}"
188
+ end
189
+ @mode_aliases[modes]
190
+ else
191
+ modes
192
+ end
193
+ end
194
+
195
+ def predicate_suppression(entry)
196
+ if entry.unless_predicate && entry.unless_predicate.call
197
+ return "unless predicate matched"
198
+ end
199
+ if entry.if_predicate && !entry.if_predicate.call
200
+ return "if predicate did not match"
201
+ end
202
+ nil
203
+ end
204
+
205
+ # Builds the metadata-only portion of an audit row. The caller is
206
+ # responsible for setting `:status` (and optionally `:reason`) — keeping
207
+ # status off the base intentionally so a future code path that forgets
208
+ # to set it produces a missing-key error instead of a silent `nil`.
209
+ def base_row(entry, mode_str)
210
+ row = { name: entry.name, level: entry.level, mode: mode_str }
211
+ row[:description] = entry.description if entry.description
212
+ row[:consumed_by] = serialize_consumed_by(entry.consumed_by) if entry.consumed_by
213
+ row[:group] = entry.group if entry.group
214
+ row
215
+ end
216
+
217
+ def serialize_consumed_by(value)
218
+ value.length == 1 ? value.first : value
219
+ end
220
+
98
221
  def classify(entry, value)
99
222
  present = !value.nil? && !value.to_s.empty?
100
223
  return :ok if present
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StandardHealth
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_health
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim