anyway_config 2.8.1 → 2.9.1

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: c2505731c8729ea6a097cef8718c441684124da047a6856bd6156b4592c1ad9b
4
- data.tar.gz: d20b15281220f9c3d77b7440ffcf915db6b28e6c7555ecec5ac9e4583de34887
3
+ metadata.gz: 5b4393c06a6078956ade1727f37879c8a38003806b59c8c5a7e9c330708a42fd
4
+ data.tar.gz: 98e3e7d9c0a50e2a18f98a917818ab4fa088314471102d6d6f9e67c08b059670
5
5
  SHA512:
6
- metadata.gz: 70db78ae8a9859a4f660173101127d4e8d30c6a059f5b3cba121864be120a787a5998f740a948651f84c82cb22e87f7bce7926a4ad6b27352297dbc9f24d20b4
7
- data.tar.gz: a314943f0a1fe0779235a75656d80cbf7a1525bbd6db24ab4fe15f508d4ac3c2790819a6d9abf39682c17c0bb0fadf4184976e9a9753dc3d61f594b910bd211a
6
+ metadata.gz: c9a771b5fd116be880f4aa3271a5389c6023500452eb8ea98ff8cefc5452ad0f24e3eab97bc02d39daab5231a070e4441efed4a285331d64f7f329073bfbab38
7
+ data.tar.gz: 8af2c0fdc71913929d78ef07f84db3b440cfa0a324834958f3bce0b77dd90e5a9c6995a61c3ba5637008171f6e02d7c7a429f46470b2d5a15984bcc524698737
data/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 2.9.0 (2026-10-21)
6
+
7
+ - Add an agent skill (`skills/anyway-config`) with guidance on organizing configuration in Rails apps. ([@palkan][])
8
+
9
+ The skill follows the [skills.sh](https://www.skills.sh) layout and installs automatically with [Rails Hyperdrive](https://github.com/rails-hyperdrive/rails-hyperdrive).
10
+
11
+ ## 2.9.0 (2026-10-20)
12
+
13
+ - Add a new `enforce_config_schema` to warn or raise on unknown attributes. ([@palkan][])
14
+
15
+ When the schema is enforced, the config attributes defined in the key-based sources (YAML, credentials, etc.) must correspond to `attr_config` names in the corresponding class. Providing an unknown attribute (e.g., a typo) would raise or print a warning depending on the parameter value:
16
+
17
+ ```ruby
18
+ Anyway::Settings.enforce_config_schema = true # or :warn
19
+
20
+ # in Rails
21
+ config.anyway_config.enforce_config_schema = true
22
+ ```
23
+
24
+ - EJSON loader now ignores underscore-prefixed (unencrypted) keys. ([@palkan][])
25
+
5
26
  ## 2.8.1 (2026-07-22)
6
27
 
7
28
  - Fix default loaders order to keep ENV the last one (=higher precedence). ([@palkan][])
@@ -9,6 +30,7 @@
9
30
  - Guard against a missing `PATH` environment variable in `Anyway::Utils.which`. ([@SAY-5][])
10
31
 
11
32
  - Do not require optional loader dependencies to speed up loading. ([@ardecvz][])
33
+ - Use `to_s` on values that support it during validation for required attributes. This provides more consistent behaviour for the `:uri` coercer, which will otherwise produce a `#<URI::Generic >` rather than a validation failure. ([@phil-janeapp][])
12
34
 
13
35
  ## 2.8.0 (2026-06-24)
14
36
 
data/README.md CHANGED
@@ -22,8 +22,9 @@ For application developers, Anyway Config could be useful to:
22
22
  **NOTE:** this readme shows documentation for 2.x version.
23
23
  For version 1.x see the [1-4-stable branch](https://github.com/palkan/anyway_config/tree/1-4-stable).
24
24
 
25
- <a href="https://evilmartians.com/?utm_source=anyway_config">
26
- <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54"></a>
25
+ <br/>
26
+
27
+ <img src="https://cdn.evilmartians.com/badges/logo-no-label.svg" alt="Evil Martians logo" width="22" height="16" /> <b>Anyway Config</b> is built by <b><a href="https://evilmartians.com/">Evil Martians</a></b>, an American design and engineering consultancy for <b>developer tools, AI, and cybersecurity startups</b>.
27
28
 
28
29
  ## Links
29
30
 
@@ -33,10 +34,12 @@ For version 1.x see the [1-4-stable branch](https://github.com/palkan/anyway_con
33
34
 
34
35
  - [Main concepts](#main-concepts)
35
36
  - [Installation](#installation)
37
+ - [Adding agent skills](#adding-agent-skills)
36
38
  - [Usage](#usage)
37
39
  - [Configuration classes](#configuration-classes)
38
40
  - [Dynamic configuration](#dynamic-configuration)
39
41
  - [Validation & Callbacks](#validation-and-callbacks)
42
+ - [Enforcing config schema](#enforcing-config-schema)
40
43
  - [Using with Rails applications](#using-with-rails)
41
44
  - [Data population](#data-population)
42
45
  - [Organizing configs](#organizing-configs)
@@ -103,6 +106,18 @@ Or adding to your project:
103
106
  gem "anyway_config", "~> 2.0"
104
107
  ```
105
108
 
109
+ ### Adding agent skills
110
+
111
+ The gem ships a skill for coding agents in `skills/anyway-config`. It guides an agent when it adds or changes configuration in a Rails app: where to put a config class, which source to use for a value, how to replace direct `ENV` reads, and how to test configs. It follows the [skills.sh](https://www.skills.sh) layout, so it works with any agent that reads `SKILL.md`.
112
+
113
+ With [Rails Hyperdrive](https://github.com/rails-hyperdrive/rails-hyperdrive), the skill installs automatically: run `bin/rails hyperdrive:init` the first time, then `bin/rails hyperdrive:sync`.
114
+
115
+ Without it, install the skill from the repository with the [skills.sh](https://www.skills.sh) CLI, or copy `skills/anyway-config` into your agent's skills directory:
116
+
117
+ ```sh
118
+ npx skills add palkan/anyway_config
119
+ ```
120
+
106
121
  ### Supported Ruby versions
107
122
 
108
123
  - Ruby (MRI) >= 2.7.0
@@ -345,6 +360,45 @@ class MyConfig < Anyway::Config
345
360
  end
346
361
  ```
347
362
 
363
+ ### Enforcing config schema
364
+
365
+ By default, Anyway Config ignores keys that don't match any of the config attributes. Thus, a typo in a YAML file (e.g., `hots` instead of `host`) goes unnoticed. You can make Anyway Config check for unknown attributes via the `enforce_config_schema` setting:
366
+
367
+ ```ruby
368
+ # Raise Anyway::Config::UnknownAttributesError
369
+ Anyway::Settings.enforce_config_schema = true
370
+
371
+ # Print a warning instead
372
+ Anyway::Settings.enforce_config_schema = :warn
373
+
374
+ # Or in Rails
375
+ config.anyway_config.enforce_config_schema = true
376
+ ```
377
+
378
+ The schema is only enforced for key-based (_schemaful_) sources: YAML files, Rails credentials and secrets, and EJSON. Environment-based sources (ENV, Doppler) are never checked, since env prefixes can easily collide with unrelated variables (e.g., `AWS_PROFILE` for the `aws` config).
379
+
380
+ Only top-level keys are checked; nested hashes are not validated.
381
+
382
+ #### Allowing unknown attributes for some configs
383
+
384
+ You can use the `rescue_unknown_attributes_with` configuration parameter to ignore specific attributes. It's called for every unknown attribute with the config object and the attribute name; return a truthy value to skip (allow) it:
385
+
386
+ ```ruby
387
+ Anyway::Settings.rescue_unknown_attributes_with = ->(config, key) do
388
+ config.config_name == "app"
389
+ end
390
+ ```
391
+
392
+ This is particularly useful when you have multiple config classes associated with the same configurable entity (say, a service provider).
393
+
394
+ #### Unknown explicit values
395
+
396
+ Unknown attributes passed explicitly (`MyConfig.new(hots: "localhost")`) will raise disregards the `enforce_config_schema` setting in the future. Currently, they follow the `enforce_config_schema` configuration but we recommend to opt-in for the stricter future behaviour:
397
+
398
+ ```ruby
399
+ Anyway::Settings.future.use :reject_unknown_overrides
400
+ ```
401
+
348
402
  ## Using with Rails
349
403
 
350
404
  **NOTE:** version 2.x supports Rails >= 6.0; for older versions use version 1.x of the gem.
@@ -825,6 +879,8 @@ class MyConfig < Anyway::Config
825
879
  end
826
880
  ```
827
881
 
882
+ Underscore-prefixed keys (which are not encrypted by EJSON, e.g., `_public_key`) are ignored.
883
+
828
884
  **NOTE:** You can opt-out from EJSON loader by specifying the `ANYWAY_CONFIG_DISABLE_EJSON=true` env var (in case you have the `ejson` executable in your PATH, but don't want to use it with Anyway Config).
829
885
 
830
886
  ### Custom loaders
@@ -865,6 +921,9 @@ class ChamberConfigLoader < Base
865
921
  warn "Couldn't decrypt Chamber settings: #{e.message}"
866
922
  {}
867
923
  end
924
+
925
+ # Report unknown keys if enforce_config_schema is enabled
926
+ def schemaful? = true
868
927
  end
869
928
 
870
929
  # Don't forget to register it
@@ -28,6 +28,8 @@ module Anyway
28
28
  Utils.deep_merge!(base_config, local_config)
29
29
  end
30
30
 
31
+ def schemaful?() ; true; end
32
+
31
33
  private
32
34
 
33
35
  def environmental?(parsed_yml)
@@ -38,6 +38,7 @@ module Anyway
38
38
  end
39
39
 
40
40
  setting :unwrap_known_environments, true
41
+ setting :reject_unknown_overrides, true
41
42
 
42
43
  private
43
44
 
@@ -55,6 +56,14 @@ module Anyway
55
56
  # Suppress required validations for CI/CD pipelines
56
57
  attr_accessor :suppress_required_validations
57
58
 
59
+ # Whether to check for unknown attributes in schemaful (key-based) sources:
60
+ # false — ignore unknown attributes, true — raise, :warn — print a warning
61
+ attr_accessor :enforce_config_schema
62
+
63
+ # A callable (config, key) -> bool to ignore specific unknown attributes
64
+ # (e.g., when the same source is shared by multiple configs)
65
+ attr_accessor :rescue_unknown_attributes_with
66
+
58
67
  # A proc returning a path to YML config file given the config name
59
68
  attr_reader :default_config_path
60
69
 
@@ -113,5 +122,8 @@ module Anyway
113
122
 
114
123
  # By default, use ANYWAY_SUPPRESS_VALIDATIONS
115
124
  self.suppress_required_validations = %w[1 t true y yes].include?(ENV["ANYWAY_SUPPRESS_VALIDATIONS"])
125
+
126
+ # Unknown attributes are ignored by default
127
+ self.enforce_config_schema = false
116
128
  end
117
129
  end
@@ -33,6 +33,7 @@ module Anyway # :nodoc:
33
33
  deconstruct_keys
34
34
  dig
35
35
  dup
36
+ format_unknown_attribute
36
37
  initialize
37
38
  load
38
39
  load_from_sources
@@ -52,6 +53,8 @@ module Anyway # :nodoc:
52
53
 
53
54
  class ValidationError < Error; end
54
55
 
56
+ class UnknownAttributesError < ValidationError; end
57
+
55
58
  include OptparseConfig
56
59
  include DynamicConfig
57
60
 
@@ -374,6 +377,8 @@ module Anyway # :nodoc:
374
377
  )
375
378
 
376
379
  if overrides
380
+ reject_unknown_config_attributes!(overrides, source: :overrides, strict: Settings.future.reject_unknown_overrides == true)
381
+
377
382
  Tracing.trace!(:load) { overrides }
378
383
 
379
384
  Utils.deep_merge!(base_config, overrides)
@@ -400,10 +405,16 @@ module Anyway # :nodoc:
400
405
  def load_from_sources(base_config, **opts)
401
406
  source_filter = self.class.configuration_sources
402
407
 
403
- Anyway.loaders.each do |(id, loader)|
408
+ Anyway.loaders.each do |(id, loader_or_proc)|
404
409
  next if source_filter && !source_filter.include?(id)
405
- Utils.deep_merge!(base_config, loader.call(**opts))
410
+
411
+ loader = (loader_or_proc.is_a?(Class) && loader_or_proc <= Loaders::Base) ? loader_or_proc.new(**opts) : loader_or_proc
412
+ loader_data = loader.call(**opts)
413
+ reject_unknown_config_attributes!(loader_data, source: id) if loader.is_a?(Loaders::Base) && loader.schemaful?
414
+
415
+ Utils.deep_merge!(base_config, loader_data)
406
416
  end
417
+
407
418
  base_config
408
419
  end
409
420
 
@@ -460,13 +471,50 @@ module Anyway # :nodoc:
460
471
 
461
472
  self.class.required_attributes.select do |name|
462
473
  val = values.dig(*name.to_s.split(".").map(&:to_sym))
463
- val.nil? || (val.is_a?(String) && val.empty?)
474
+ val.nil? || (val.respond_to?(:to_s) && val.to_s.empty?)
464
475
  end.then do |missing|
465
476
  next if missing.empty?
466
477
  raise_validation_error "The following config parameters for `#{self.class.name}(config_name: #{self.class.config_name})` are missing or empty: #{missing.join(", ")}"
467
478
  end
468
479
  end
469
480
 
481
+ def reject_unknown_config_attributes!(data, source:, strict: false)
482
+ return if data.nil? || data.empty?
483
+
484
+ mode = strict || Settings.enforce_config_schema
485
+ return unless mode
486
+
487
+ known = self.class.config_attributes.map(&:to_s)
488
+ unknown = data.keys.map(&:to_s) - known
489
+
490
+ unless strict
491
+ rescuer = Settings.rescue_unknown_attributes_with
492
+ unknown.reject! { |it| rescuer.call(self, it) } if rescuer
493
+ end
494
+
495
+ return if unknown.empty?
496
+
497
+ msg = "Unknown config attributes for `#{self.class.name || "<anonymous>"}(config_name: #{config_name})` " \
498
+ "provided by #{source}: #{unknown.map { |it| format_unknown_attribute(it, known) }.join(", ")}"
499
+
500
+ if strict
501
+ raise ArgumentError, msg
502
+ elsif mode == :warn
503
+ Kernel.warn msg
504
+ else
505
+ raise UnknownAttributesError, msg
506
+ end
507
+ end
508
+
509
+ def format_unknown_attribute(key, known)
510
+ return key unless defined?(::DidYouMean::SpellChecker)
511
+
512
+ suggestions = ::DidYouMean::SpellChecker.new(dictionary: known).correct(key)
513
+ return key if suggestions.empty?
514
+
515
+ "#{key} (did you mean: #{suggestions.join(", ")}?)"
516
+ end
517
+
470
518
  def write_config_attr(key, val)
471
519
  key = key.to_sym
472
520
  return unless self.class.config_attributes.include?(key)
@@ -11,11 +11,15 @@ module Anyway
11
11
  end
12
12
  end
13
13
 
14
- def initialize(local:)
14
+ def initialize(local: Anyway::Settings.use_local_files, **__kwrest__)
15
15
  @local = local
16
16
  end
17
17
 
18
18
  def use_local?() ; @local == true; end
19
+
20
+ # Whether this loader supports strict schema (so we can warn or raise on
21
+ # unknown attributes)
22
+ def schemaful?() ; false; end
19
23
  end
20
24
  end
21
25
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anyway/ejson_parser"
4
+
5
+ module Anyway
6
+ module Loaders
7
+ class EJSON < Base
8
+ class << self
9
+ attr_accessor :bin_path
10
+ end
11
+
12
+ self.bin_path = "ejson"
13
+
14
+ def call(name:, ejson_namespace: name, ejson_parser: Anyway::EJSONParser.new(EJSON.bin_path), **_options)
15
+ configs = []
16
+
17
+ rel_config_paths.each do |rel_config_path|
18
+ secrets_hash, rel_path =
19
+ extract_hash_from_rel_config_path(
20
+ ejson_parser: ejson_parser,
21
+ rel_config_path: rel_config_path
22
+ )
23
+
24
+ next unless secrets_hash
25
+
26
+ config_hash = if ejson_namespace
27
+ secrets_hash[ejson_namespace]
28
+ else
29
+ secrets_hash
30
+ end
31
+
32
+ next unless config_hash.is_a?(Hash)
33
+
34
+ # Underscore-prefixed keys are not encrypted (e.g., _public_key)
35
+ config_hash = config_hash.reject { |key, _| key.start_with?("_") }
36
+
37
+ configs <<
38
+ trace!(:ejson, path: rel_path) do
39
+ config_hash
40
+ end
41
+ end
42
+
43
+ return {} if configs.empty?
44
+
45
+ configs.inject do |result_config, next_config|
46
+ Utils.deep_merge!(result_config, next_config)
47
+ end
48
+ end
49
+
50
+ def schemaful?() ; true; end
51
+
52
+ private
53
+
54
+ def rel_config_paths
55
+ chain = [environmental_rel_config_path]
56
+
57
+ chain << "secrets.local.ejson" if use_local?
58
+
59
+ chain
60
+ end
61
+
62
+ def environmental_rel_config_path
63
+ if Settings.current_environment
64
+ # if environment file is absent, then take data from the default one
65
+ [
66
+ "#{Settings.current_environment}/secrets.ejson",
67
+ default_rel_config_path
68
+ ]
69
+ else
70
+ default_rel_config_path
71
+ end
72
+ end
73
+
74
+ def default_rel_config_path
75
+ "secrets.ejson"
76
+ end
77
+
78
+ def extract_hash_from_rel_config_path(ejson_parser:, rel_config_path:)
79
+ rel_config_path = [rel_config_path] unless rel_config_path.is_a?(Array)
80
+
81
+ rel_config_path.each do |rel_conf_path|
82
+ rel_path = "config/#{rel_conf_path}"
83
+ abs_path = "#{Settings.app_root}/#{rel_path}"
84
+
85
+ result = ejson_parser.call(abs_path)
86
+
87
+ return [result, rel_path] if result
88
+ end
89
+
90
+ nil
91
+ end
92
+ end
93
+ end
94
+ end
@@ -28,6 +28,8 @@ module Anyway
28
28
  Utils.deep_merge!(base_config, local_config)
29
29
  end
30
30
 
31
+ def schemaful?() ; true; end
32
+
31
33
  private
32
34
 
33
35
  def environmental?(parsed_yml)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anyway
4
+ module Rails
5
+ module Loaders
6
+ class Credentials < Anyway::Loaders::Base
7
+ LOCAL_CONTENT_PATH = "config/credentials/local.yml.enc"
8
+
9
+ def call(name:, **_options)
10
+ return {} unless ::Rails.application.respond_to?(:credentials)
11
+
12
+ # do not load from credentials if we're in the context
13
+ # of the `credentials:edit` command
14
+ return {} if defined?(::Rails::Command::CredentialsCommand)
15
+
16
+ # Create a new hash cause credentials are mutable!
17
+ config = {}
18
+
19
+ trace!(
20
+ :credentials,
21
+ store: credentials_path
22
+ ) do
23
+ ::Rails.application.credentials.config[name.to_sym]
24
+ end.then do |creds|
25
+ Utils.deep_merge!(config, creds) if creds
26
+ end
27
+
28
+ if use_local?
29
+ trace!(:credentials, store: LOCAL_CONTENT_PATH) do
30
+ local_credentials(name)
31
+ end.then { |creds| Utils.deep_merge!(config, creds) if creds }
32
+ end
33
+
34
+ config
35
+ end
36
+
37
+ def schemaful?() ; true; end
38
+
39
+ private
40
+
41
+ def local_credentials(name)
42
+ local_creds_path = ::Rails.root.join(LOCAL_CONTENT_PATH).to_s
43
+
44
+ return unless File.file?(local_creds_path)
45
+
46
+ creds = ::Rails.application.encrypted(
47
+ local_creds_path,
48
+ key_path: ::Rails.root.join("config/credentials/local.key")
49
+ )
50
+
51
+ creds.config[name.to_sym]
52
+ end
53
+
54
+ def credentials_path
55
+ if ::Rails.application.config.respond_to?(:credentials)
56
+ ::Rails.root.join(::Rails.application.config.credentials.content_path).relative_path_from(::Rails.root).to_s
57
+ else
58
+ "config/credentials.yml.enc"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anyway
4
+ module Rails
5
+ module Loaders
6
+ class Secrets < Anyway::Loaders::Base
7
+ def call(name:, **_options)
8
+ return {} unless ::Rails.application.respond_to?(:secrets)
9
+
10
+ # Create a new hash cause secrets are mutable!
11
+ config = {}
12
+
13
+ trace!(:secrets) do
14
+ secrets.public_send(name)
15
+ end.then do |secrets|
16
+ Utils.deep_merge!(config, secrets) if secrets
17
+ end
18
+
19
+ config
20
+ end
21
+
22
+ def schemaful?() ; true; end
23
+
24
+ private
25
+
26
+ def secrets
27
+ @secrets ||= ::Rails.application.secrets.tap do |_|
28
+ # Reset secrets state if the app hasn't been initialized
29
+ # See https://github.com/palkan/anyway_config/issues/14
30
+ next if ::Rails.application.initialized?
31
+
32
+ # Address unable to load application: NameError: instance variable @secrets
33
+ # not defined
34
+ next unless ::Rails.application.instance_variable_defined?(:@secrets)
35
+
36
+ ::Rails.application.remove_instance_variable(:@secrets)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -33,6 +33,7 @@ module Anyway # :nodoc:
33
33
  deconstruct_keys
34
34
  dig
35
35
  dup
36
+ format_unknown_attribute
36
37
  initialize
37
38
  load
38
39
  load_from_sources
@@ -52,6 +53,8 @@ module Anyway # :nodoc:
52
53
 
53
54
  class ValidationError < Error; end
54
55
 
56
+ class UnknownAttributesError < ValidationError; end
57
+
55
58
  include OptparseConfig
56
59
  include DynamicConfig
57
60
 
@@ -374,6 +377,8 @@ module Anyway # :nodoc:
374
377
  )
375
378
 
376
379
  if overrides
380
+ reject_unknown_config_attributes!(overrides, source: :overrides, strict: Settings.future.reject_unknown_overrides == true)
381
+
377
382
  Tracing.trace!(:load) { overrides }
378
383
 
379
384
  Utils.deep_merge!(base_config, overrides)
@@ -400,10 +405,16 @@ module Anyway # :nodoc:
400
405
  def load_from_sources(base_config, **opts)
401
406
  source_filter = self.class.configuration_sources
402
407
 
403
- Anyway.loaders.each do |(id, loader)|
408
+ Anyway.loaders.each do |(id, loader_or_proc)|
404
409
  next if source_filter && !source_filter.include?(id)
405
- Utils.deep_merge!(base_config, loader.call(**opts))
410
+
411
+ loader = (loader_or_proc.is_a?(Class) && loader_or_proc <= Loaders::Base) ? loader_or_proc.new(**opts) : loader_or_proc
412
+ loader_data = loader.call(**opts)
413
+ reject_unknown_config_attributes!(loader_data, source: id) if loader.is_a?(Loaders::Base) && loader.schemaful?
414
+
415
+ Utils.deep_merge!(base_config, loader_data)
406
416
  end
417
+
407
418
  base_config
408
419
  end
409
420
 
@@ -460,13 +471,50 @@ module Anyway # :nodoc:
460
471
 
461
472
  self.class.required_attributes.select do |name|
462
473
  val = values.dig(*name.to_s.split(".").map(&:to_sym))
463
- val.nil? || (val.is_a?(String) && val.empty?)
474
+ val.nil? || (val.respond_to?(:to_s) && val.to_s.empty?)
464
475
  end.then do |missing|
465
476
  next if missing.empty?
466
477
  raise_validation_error "The following config parameters for `#{self.class.name}(config_name: #{self.class.config_name})` are missing or empty: #{missing.join(", ")}"
467
478
  end
468
479
  end
469
480
 
481
+ def reject_unknown_config_attributes!(data, source:, strict: false)
482
+ return if data.nil? || data.empty?
483
+
484
+ mode = strict || Settings.enforce_config_schema
485
+ return unless mode
486
+
487
+ known = self.class.config_attributes.map(&:to_s)
488
+ unknown = data.keys.map(&:to_s) - known
489
+
490
+ unless strict
491
+ rescuer = Settings.rescue_unknown_attributes_with
492
+ unknown.reject! { |it| rescuer.call(self, it) } if rescuer
493
+ end
494
+
495
+ return if unknown.empty?
496
+
497
+ msg = "Unknown config attributes for `#{self.class.name || "<anonymous>"}(config_name: #{config_name})` " \
498
+ "provided by #{source}: #{unknown.map { |it| format_unknown_attribute(it, known) }.join(", ")}"
499
+
500
+ if strict
501
+ raise ArgumentError, msg
502
+ elsif mode == :warn
503
+ Kernel.warn msg
504
+ else
505
+ raise UnknownAttributesError, msg
506
+ end
507
+ end
508
+
509
+ def format_unknown_attribute(key, known)
510
+ return key unless defined?(::DidYouMean::SpellChecker)
511
+
512
+ suggestions = ::DidYouMean::SpellChecker.new(dictionary: known).correct(key)
513
+ return key if suggestions.empty?
514
+
515
+ "#{key} (did you mean: #{suggestions.join(", ")}?)"
516
+ end
517
+
470
518
  def write_config_attr(key, val)
471
519
  key = key.to_sym
472
520
  return unless self.class.config_attributes.include?(key)
@@ -11,11 +11,15 @@ module Anyway
11
11
  end
12
12
  end
13
13
 
14
- def initialize(local:)
14
+ def initialize(local: Anyway::Settings.use_local_files, **__kwrest__)
15
15
  @local = local
16
16
  end
17
17
 
18
18
  def use_local?() = @local == true
19
+
20
+ # Whether this loader supports strict schema (so we can warn or raise on
21
+ # unknown attributes)
22
+ def schemaful?() = false
19
23
  end
20
24
  end
21
25
  end