standard_health 0.2.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +105 -2
- data/app/controllers/standard_health/diagnostics_controller.rb +2 -1
- data/lib/standard_health/env_spec.rb +220 -32
- data/lib/standard_health/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 73c1743f46e03601e44970d282ca02dd195b22e5f22fe502e20e8a45281e0d69
|
|
4
|
+
data.tar.gz: 507f8e5fda5420690b7146b0f6a9136561c0a1b61d6c567c5be3dcd5c250be61
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 38b527cf8973dc2557b1b8c13e40ace60f73efb78f1aab34100b4a51cf4f53069700c7ac0047f038a9bcf5fdcb9a1f3223fa92ddc653e1293f6c5d4b51f320ed
|
|
7
|
+
data.tar.gz: 3662477381f1149d26394ba63fc2380c2104d1cb78a65bf3d943681a907b591ad0e7e9a6f990c868cf48087306cf6cb845c399fd976a346858ba083647b6bc3e
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-05-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Consumer-presence detection for `consumed_by:` paths. `audit()` accepts an optional `root:` keyword (host-app root). When given, each entry whose `consumed_by:` is set is checked against the host app's tree, producing a new `consumer:` field on the audit row: `:present` (file exists and references the var via `ENV[...]` or `ENV.fetch(...)`), `:file_missing` (path missing on disk), or `:not_referenced` (file exists but no `ENV` reference). Catches renamed/deleted consumer files, `consumed_by:` typos, and vars declared in env-spec but never actually `ENV.fetch`'d.
|
|
15
|
+
- `DiagnosticsController#env` now passes `Rails.root` automatically, so host apps get the new `consumer:` field with no host-side change.
|
|
16
|
+
- Deprecation metadata on `required` / `recommended`: `deprecated: true`, `sunset_on:` (target removal date), `replacement:` (what to use instead). Surfaced verbatim in audit rows. Lets vars be staged for removal with audit trail.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- `Entry` struct extended with `deprecated`, `sunset_on`, `replacement`. Backward-compatible — every 0.3.0 spec produces identical audit output when the new opts aren't used and `root:` isn't passed.
|
|
21
|
+
|
|
22
|
+
## [0.3.0] - 2026-04-29
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `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.
|
|
27
|
+
- `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`.
|
|
28
|
+
- `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.
|
|
29
|
+
- `group "Label" do ... end` block inside `EnvSpec.define`. Tags enclosed entries with a `group` field in their audit rows. Pure metadata for ops UX.
|
|
30
|
+
- New `:not_applicable` value for the `status` field, used when an `if:`/`unless:` predicate suppresses an entry.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- `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).
|
|
35
|
+
|
|
10
36
|
## [0.2.0] - 2026-04-29
|
|
11
37
|
|
|
12
38
|
### 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),
|
|
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
|
|
|
@@ -19,8 +19,9 @@ module StandardHealth
|
|
|
19
19
|
def env
|
|
20
20
|
spec = StandardHealth.config.env_spec
|
|
21
21
|
mode = ENV["APP_ENVIRONMENT"].to_s
|
|
22
|
+
root = defined?(Rails) ? Rails.root : nil
|
|
22
23
|
|
|
23
|
-
audit = spec ? spec.audit(ENV.to_h, mode: mode) : []
|
|
24
|
+
audit = spec ? spec.audit(ENV.to_h, mode: mode, root: root) : []
|
|
24
25
|
|
|
25
26
|
render json: {
|
|
26
27
|
mode: mode,
|
|
@@ -6,32 +6,75 @@ 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
|
-
# - `
|
|
18
|
-
# while `APP_ENVIRONMENT` matches one of these modes.
|
|
19
|
-
#
|
|
20
|
-
#
|
|
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. When
|
|
35
|
+
# `audit` is called with `root:`, each path is checked for an
|
|
36
|
+
# `ENV[...]` / `ENV.fetch(...)` reference to the var; the result is
|
|
37
|
+
# reported as `consumer:` in the audit row
|
|
38
|
+
# (`:present`, `:file_missing`, or `:not_referenced`).
|
|
39
|
+
# - `if:` / `unless:` (Proc, optional) — predicates evaluated at audit
|
|
40
|
+
# time. When `unless:` returns truthy or `if:` returns falsy the entry
|
|
41
|
+
# is reported with `status: :not_applicable`.
|
|
42
|
+
# - `group` (String, optional) — set implicitly by enclosing `group`
|
|
43
|
+
# block; surfaced verbatim.
|
|
44
|
+
# - `deprecated:` (Boolean, optional) — flag for staged removal. When
|
|
45
|
+
# true, the audit row includes `deprecated: true`.
|
|
46
|
+
# - `sunset_on:` (String/Date, optional) — target removal date.
|
|
47
|
+
# Surfaced verbatim in audit rows.
|
|
48
|
+
# - `replacement:` (String, optional) — what to use instead. Surfaced
|
|
49
|
+
# in audit rows.
|
|
22
50
|
class EnvSpec
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
51
|
+
# Raised when `in:` references a Symbol that hasn't been declared via
|
|
52
|
+
# `mode_alias`.
|
|
53
|
+
class UnknownModeAlias < ArgumentError; end
|
|
54
|
+
|
|
55
|
+
# Internal entry record. `modes` may be an Array<String> or a Symbol
|
|
56
|
+
# alias that gets resolved at audit time.
|
|
57
|
+
Entry = Struct.new(
|
|
58
|
+
:name,
|
|
59
|
+
:level,
|
|
60
|
+
:modes,
|
|
61
|
+
:description,
|
|
62
|
+
:consumed_by,
|
|
63
|
+
:if_predicate,
|
|
64
|
+
:unless_predicate,
|
|
65
|
+
:group,
|
|
66
|
+
:deprecated,
|
|
67
|
+
:sunset_on,
|
|
68
|
+
:replacement,
|
|
69
|
+
keyword_init: true
|
|
70
|
+
)
|
|
31
71
|
|
|
32
72
|
# @return [Array<Entry>]
|
|
33
73
|
attr_reader :entries
|
|
34
74
|
|
|
75
|
+
# @return [Hash{Symbol => Array<String>}]
|
|
76
|
+
attr_reader :mode_aliases
|
|
77
|
+
|
|
35
78
|
# Build a spec via the DSL.
|
|
36
79
|
def self.define(&block)
|
|
37
80
|
new.tap { |spec| spec.instance_eval(&block) if block }
|
|
@@ -39,13 +82,42 @@ module StandardHealth
|
|
|
39
82
|
|
|
40
83
|
def initialize
|
|
41
84
|
@entries = []
|
|
85
|
+
@mode_aliases = {}
|
|
86
|
+
@group_stack = []
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Declare a mode alias usable in `in:`. Re-declaring overrides the
|
|
90
|
+
# previous value (last writer wins) so layered specs are easy to compose.
|
|
91
|
+
#
|
|
92
|
+
# @param name [Symbol]
|
|
93
|
+
# @param modes [Array<String>]
|
|
94
|
+
def mode_alias(name, modes)
|
|
95
|
+
@mode_aliases[name.to_sym] = Array(modes).map(&:to_s)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Group subsequent declarations under a label. Pure metadata —
|
|
99
|
+
# propagated to audit rows as `group:` and otherwise inert. Nested
|
|
100
|
+
# `group` blocks are supported; the innermost label is the one that
|
|
101
|
+
# propagates to enclosed entries.
|
|
102
|
+
#
|
|
103
|
+
# @param label [String]
|
|
104
|
+
def group(label, &block)
|
|
105
|
+
raise ArgumentError, "group requires a block" unless block
|
|
106
|
+
|
|
107
|
+
@group_stack.push(label.to_s)
|
|
108
|
+
block.call
|
|
109
|
+
ensure
|
|
110
|
+
@group_stack.pop
|
|
42
111
|
end
|
|
43
112
|
|
|
44
113
|
# Declare a required env var.
|
|
45
114
|
#
|
|
46
115
|
# @param name [Symbol, String]
|
|
47
|
-
# @param in [Array<String>, nil] limit applicability to these modes
|
|
116
|
+
# @param in [Array<String>, Symbol, nil] limit applicability to these modes
|
|
48
117
|
# @param description [String, nil]
|
|
118
|
+
# @param consumed_by [String, Array<String>, nil]
|
|
119
|
+
# @param if [Proc, nil]
|
|
120
|
+
# @param unless [Proc, nil]
|
|
49
121
|
def required(name, **opts)
|
|
50
122
|
add(:required, name, **opts)
|
|
51
123
|
end
|
|
@@ -60,41 +132,157 @@ module StandardHealth
|
|
|
60
132
|
#
|
|
61
133
|
# @param env_hash [Hash{String, Symbol => String}] e.g. ENV.to_h
|
|
62
134
|
# @param mode [String, Symbol] current APP_ENVIRONMENT value
|
|
63
|
-
# @
|
|
64
|
-
#
|
|
65
|
-
|
|
135
|
+
# @param root [String, Pathname, nil] host app root for resolving
|
|
136
|
+
# `consumed_by` paths. When given, each row gains a `consumer:` field:
|
|
137
|
+
# `:present` (file exists and references the var),
|
|
138
|
+
# `:file_missing` (path does not exist),
|
|
139
|
+
# `:not_referenced` (file exists but no `ENV[...]` / `ENV.fetch(...)`
|
|
140
|
+
# match for the var). When the entry has no `consumed_by` or `root`
|
|
141
|
+
# is nil, the field is omitted.
|
|
142
|
+
# @return [Array<Hash>] one row per applicable entry. Each row has at
|
|
143
|
+
# least `name`, `level`, `status`, `mode`. When an entry is suppressed
|
|
144
|
+
# by an `if:`/`unless:` predicate, `status` is `:not_applicable` and a
|
|
145
|
+
# `reason` field explains why. `description`, `consumed_by`, `group`,
|
|
146
|
+
# `deprecated`, `sunset_on`, `replacement`, and `consumer` are
|
|
147
|
+
# included when set on the entry / computed during audit.
|
|
148
|
+
def audit(env_hash, mode:, root: nil)
|
|
66
149
|
mode_str = mode.to_s
|
|
67
150
|
env = stringify(env_hash)
|
|
68
151
|
|
|
69
152
|
@entries.each_with_object([]) do |entry, out|
|
|
70
|
-
next unless
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
153
|
+
next unless mode_applies?(entry, mode_str)
|
|
154
|
+
|
|
155
|
+
row = base_row(entry, mode_str)
|
|
156
|
+
|
|
157
|
+
suppression = predicate_suppression(entry)
|
|
158
|
+
if suppression
|
|
159
|
+
row[:status] = :not_applicable
|
|
160
|
+
row[:reason] = suppression
|
|
161
|
+
else
|
|
162
|
+
value = env[entry.name.to_s]
|
|
163
|
+
row[:status] = classify(entry, value)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
consumer_status = consumer_state(entry, root)
|
|
167
|
+
row[:consumer] = consumer_status if consumer_status
|
|
168
|
+
|
|
82
169
|
out << row
|
|
83
170
|
end
|
|
84
171
|
end
|
|
85
172
|
|
|
86
173
|
private
|
|
87
174
|
|
|
88
|
-
def add(level, name,
|
|
89
|
-
|
|
175
|
+
def add(level, name, **opts)
|
|
176
|
+
modes_opt = opts[:in]
|
|
90
177
|
@entries << Entry.new(
|
|
91
178
|
name: name.to_sym,
|
|
92
179
|
level: level,
|
|
93
|
-
modes:
|
|
94
|
-
description: description
|
|
180
|
+
modes: normalize_modes(modes_opt),
|
|
181
|
+
description: opts[:description],
|
|
182
|
+
consumed_by: normalize_consumed_by(opts[:consumed_by]),
|
|
183
|
+
if_predicate: opts[:if],
|
|
184
|
+
unless_predicate: opts[:unless],
|
|
185
|
+
group: @group_stack.last,
|
|
186
|
+
deprecated: opts[:deprecated] ? true : nil,
|
|
187
|
+
sunset_on: opts[:sunset_on],
|
|
188
|
+
replacement: opts[:replacement]
|
|
95
189
|
)
|
|
96
190
|
end
|
|
97
191
|
|
|
192
|
+
def normalize_modes(modes_opt)
|
|
193
|
+
return nil if modes_opt.nil?
|
|
194
|
+
return modes_opt if modes_opt.is_a?(Symbol)
|
|
195
|
+
Array(modes_opt).map(&:to_s)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def normalize_consumed_by(value)
|
|
199
|
+
return nil if value.nil?
|
|
200
|
+
array = Array(value).map(&:to_s)
|
|
201
|
+
array.empty? ? nil : array
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def mode_applies?(entry, mode_str)
|
|
205
|
+
modes = resolve_modes(entry.modes)
|
|
206
|
+
return true if modes.nil? || modes.empty?
|
|
207
|
+
modes.include?(mode_str)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def resolve_modes(modes)
|
|
211
|
+
return nil if modes.nil?
|
|
212
|
+
if modes.is_a?(Symbol)
|
|
213
|
+
unless @mode_aliases.key?(modes)
|
|
214
|
+
raise UnknownModeAlias, "Unknown mode alias: #{modes.inspect}"
|
|
215
|
+
end
|
|
216
|
+
@mode_aliases[modes]
|
|
217
|
+
else
|
|
218
|
+
modes
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def predicate_suppression(entry)
|
|
223
|
+
if entry.unless_predicate && entry.unless_predicate.call
|
|
224
|
+
return "unless predicate matched"
|
|
225
|
+
end
|
|
226
|
+
if entry.if_predicate && !entry.if_predicate.call
|
|
227
|
+
return "if predicate did not match"
|
|
228
|
+
end
|
|
229
|
+
nil
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Builds the metadata-only portion of an audit row. The caller is
|
|
233
|
+
# responsible for setting `:status` (and optionally `:reason`) — keeping
|
|
234
|
+
# status off the base intentionally so a future code path that forgets
|
|
235
|
+
# to set it produces a missing-key error instead of a silent `nil`.
|
|
236
|
+
def base_row(entry, mode_str)
|
|
237
|
+
row = { name: entry.name, level: entry.level, mode: mode_str }
|
|
238
|
+
row[:description] = entry.description if entry.description
|
|
239
|
+
row[:consumed_by] = serialize_consumed_by(entry.consumed_by) if entry.consumed_by
|
|
240
|
+
row[:group] = entry.group if entry.group
|
|
241
|
+
row[:deprecated] = true if entry.deprecated
|
|
242
|
+
row[:sunset_on] = entry.sunset_on.to_s if entry.sunset_on
|
|
243
|
+
row[:replacement] = entry.replacement if entry.replacement
|
|
244
|
+
row
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Resolve the consumer-presence state for an entry. Returns nil when
|
|
248
|
+
# we have nothing to check (no root, no consumed_by). Otherwise:
|
|
249
|
+
#
|
|
250
|
+
# :present — at least one consumed_by path exists AND mentions
|
|
251
|
+
# ENV["VAR"] or ENV.fetch("VAR", ...).
|
|
252
|
+
# :file_missing — every consumed_by path is missing on disk.
|
|
253
|
+
# :not_referenced — at least one path exists, but none reference the
|
|
254
|
+
# env var.
|
|
255
|
+
def consumer_state(entry, root)
|
|
256
|
+
return nil if root.nil? || entry.consumed_by.nil?
|
|
257
|
+
|
|
258
|
+
pattern = env_reference_pattern(entry.name)
|
|
259
|
+
any_file_present = false
|
|
260
|
+
any_referenced = false
|
|
261
|
+
|
|
262
|
+
entry.consumed_by.each do |relative|
|
|
263
|
+
path = File.join(root.to_s, relative)
|
|
264
|
+
next unless File.file?(path)
|
|
265
|
+
|
|
266
|
+
any_file_present = true
|
|
267
|
+
any_referenced = true if File.read(path).match?(pattern)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
return :file_missing unless any_file_present
|
|
271
|
+
any_referenced ? :present : :not_referenced
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Match ENV["VAR"], ENV['VAR'], ENV[:VAR], ENV.fetch("VAR", ...),
|
|
275
|
+
# ENV.fetch('VAR', ...), ENV.fetch(:VAR, ...). Word-boundary on the
|
|
276
|
+
# right so VAR_PREFIX doesn't accidentally match VAR.
|
|
277
|
+
def env_reference_pattern(name)
|
|
278
|
+
escaped = Regexp.escape(name.to_s)
|
|
279
|
+
/ENV(?:\.fetch)?\s*[\[(]\s*[:'"]?#{escaped}\b/
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def serialize_consumed_by(value)
|
|
283
|
+
value.length == 1 ? value.first : value
|
|
284
|
+
end
|
|
285
|
+
|
|
98
286
|
def classify(entry, value)
|
|
99
287
|
present = !value.nil? && !value.to_s.empty?
|
|
100
288
|
return :ok if present
|