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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8542efb5b2511d31b4c9b3ef2a10f650ed342c1eeadc4aecaadef1476b82bc59
4
- data.tar.gz: 8996c9e8f67904d842f589e60d880127680afa55365cb3be835260db9d36b3c1
3
+ metadata.gz: 73c1743f46e03601e44970d282ca02dd195b22e5f22fe502e20e8a45281e0d69
4
+ data.tar.gz: 507f8e5fda5420690b7146b0f6a9136561c0a1b61d6c567c5be3dcd5c250be61
5
5
  SHA512:
6
- metadata.gz: b528859586bf219cab6b76902a230f3e660d36e4c108db6489ad1c4643fc19455c868bfa1fbe2b5fdc5291173aa04bfaa516ff322f44cecea41cd3cf5381b474
7
- data.tar.gz: fe062a3ab494d205e3d436037a4ea42fbe3802043a7d8b3ee873ff4d5b6dd1ae7d1a7bd4659a3e4c533d92cfc035fa47d7c582801a418e4ba24b8943163ae5d5
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), 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
 
@@ -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
- # - `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. 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
- 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
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
- # @return [Array<Hash>] one row per applicable entry, each shaped like:
64
- # { name:, level:, status: :ok | :missing | :should_set, mode: }
65
- def audit(env_hash, mode:)
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 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
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, in: nil, description: nil)
89
- modes = binding.local_variable_get(:in)
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: modes ? Array(modes).map(&:to_s) : nil,
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StandardHealth
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.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.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim