.vbw-planning/milestones/upgrade-assurance/phases/02-config-deprecation/PLAN-01.md
@lib/source_monitor/configuration/http_settings.rb -- Example settings class pattern. Plain Ruby class with attr_accessor and reset!. Shows the structure that deprecation checking must traverse: each settings class is a separate object accessible via config.http, config.fetching, etc. The registry needs to resolve dot-notation paths by splitting on "." and walking the config object graph.
@lib/source_monitor/configuration/fetching_settings.rb -- Another settings class showing the pattern: initialize calls reset!, attr_accessor for all fields. The deprecation framework does NOT modify these classes. It inspects them from outside by calling respond_to? and public_send.
@lib/source_monitor/configuration/retention_settings.rb -- Settings class with custom setters (strategy=). Shows that some settings use custom writer methods rather than raw attr_accessor. The deprecation framework checks if an option was SET, not just if it exists. Approach: the registry stores the old option path and checks if the config object responds to that method. For renamed options, it checks if the old method exists (it should not on current config -- if it does, someone is calling it). For removed options, if someone references a method that no longer exists, Ruby will raise NoMethodError before our check runs. So the practical approach is: register deprecations with the OLD path, and during check!, attempt to resolve the path. If the path resolves (meaning the old option still exists as an accessor), log the warning. If it does not resolve, skip silently (the option was already removed, and any access would have raised NoMethodError naturally). For :error severity removed options, we use a different approach: install a method_missing trap or explicitly define a method that raises.
Simpler design decision: The registry operates in two modes:
method_missing or define_method on the relevant settings class that logs a warning and forwards to the new option.define_method on the relevant settings class that raises.Final design (simplest): The DeprecationRegistry stores entries. When an entry is registered, it dynamically defines a method on the target settings class (or Configuration itself for top-level options) that:
The register DSL: DeprecationRegistry.register("http.proxy_url", removed_in: "0.5.0", replacement: "http.proxy", severity: :warning, message: "Use config.http.proxy instead"). The registry parses the path, finds the settings class, and defines the trapping method.
For testing, we register synthetic deprecations (e.g. a fake option "http.old_timeout") and verify that accessing it in a configure block triggers the warning/error.
@lib/source_monitor.rb lines 197-217 -- The configure method yields config, then calls ModelExtensions.reload!. The deprecation framework hooks in here. Since we chose the define_method approach (methods fire on access, not post-scan), the configure method does NOT need a post-scan call. However, adding config.check_deprecations! as a post-configure hook is still useful as a safety net and for "removed option" detection. Actually, with the define_method approach, removed options raise immediately when accessed. So the only role for check_deprecations! is belt-and-suspenders. We can keep it simple: register defines methods, no post-scan needed. But for completeness and the "zero false positives" criterion, add a check_deprecations! that the configure method calls. This method iterates all :error entries and verifies the config is clean (no-op if no errors were raised, which is guaranteed by the define_method traps). Actually, let's just keep the define_method approach and skip the post-scan entirely. The configure method stays unchanged. The registry is self-contained.
Revised final design: DeprecationRegistry is a class with class-level state (entries hash). register stores the entry and defines a method on the target class. clear! removes all registered deprecations and undefines the methods (for test isolation). entries returns the hash for inspection. No changes to SourceMonitor.configure needed -- the trapping methods fire during the configure block naturally. Add check_deprecations! to Configuration anyway for explicit post-configure validation (iterates entries, no-op for now, but extensible for future "default changed" checks).
@test/lib/source_monitor/configuration_test.rb -- Existing configuration tests with setup/teardown that calls reset_configuration!. The deprecation registry test file follows the same pattern but also clears the registry in teardown. Tests use synthetic deprecations registered in setup, then verify warning/error behavior.
@lib/source_monitor/configuration/scraping_settings.rb -- Shows custom setter pattern (normalize_numeric). If a deprecated option path targets a class that already has a custom setter, the registry-defined method must coexist. Since we define a NEW method (the old/deprecated name), there is no collision with existing methods.
</context>
<tasks>
<task type="auto">
<name>create-deprecation-registry</name>
<files>
lib/source_monitor/configuration/deprecation_registry.rb
</files>
<action>
Create lib/source_monitor/configuration/deprecation_registry.rb with the DeprecationRegistry class.
Module nesting: SourceMonitor::Configuration::DeprecationRegistry.
Class-level state (use class instance variables, not class variables):
@entries -- Hash mapping "path" to entry hash { path:, removed_in:, replacement:, severity:, message: }@defined_methods -- Array of [klass, method_name] tuples for cleanupClass methods:
register(path, removed_in:, replacement: nil, severity: :warning, message: nil)
path -- split on ".". If one segment, target class is Configuration. If two segments, first is the settings accessor name, second is the deprecated option name.Configuration::HTTPSettings. Use a mapping hash: { "http" => HTTPSettings, "fetching" => FetchingSettings, "health" => HealthSettings, "scraping" => ScrapingSettings, "retention" => RetentionSettings, "realtime" => RealtimeSettings, "authentication" => AuthenticationSettings, "images" => ImagesSettings, "scraper" => ScraperRegistry, "events" => Events, "models" => Models }. For top-level, target is Configuration."[SourceMonitor] DEPRECATION: '#{path}' was deprecated in v#{removed_in}#{replacement_text}. #{message}". Where replacement_text is and replaced by '#{replacement}' if replacement is present."#{option_name}=") on the target class:
:warning severity: the method logs via Rails.logger.warn(deprecation_message) and, if replacement is present, forwards the value to the replacement setter. If no replacement, the value is silently dropped.:error severity: the method raises SourceMonitor::DeprecatedOptionError.new(deprecation_message).option_name) for :warning that forwards to replacement getter, or for :error that raises.@entries and record [target_class, method_name] in @defined_methods.clear!
Remove all defined methods from their target classes (use remove_method), clear @entries and @defined_methods. This is essential for test isolation.
entries -- returns @entries.dup
registered?(path) -- returns boolean
Also define SourceMonitor::DeprecatedOptionError < StandardError in this file.
Key design points:
define_method is used on the target class so the trap fires during normal configure block usage.remove_method (not undef_method) is used in clear! so the class reverts to its original behavior.bin/rubocop lib/source_monitor/configuration/deprecation_registry.rb -- 0 offenses.
</verify>
<done>
DeprecationRegistry class created with register/clear!/entries/registered? class methods. Defines trapping methods on target settings classes for both :warning and :error severities. DeprecatedOptionError defined. SETTINGS_CLASSES maps all 11 settings accessor names.
</done>
</task>
Add a require at the top (after the existing requires, before the module definition):
require "source_monitor/configuration/deprecation_registry"
Add a public method to Configuration:
def check_deprecations!
DeprecationRegistry.check_defaults!(self)
end
This method is a hook point for future "default changed" checks. For now, DeprecationRegistry.check_defaults! is a no-op class method (define it in the registry). It exists so that future phases can add checks like "option X changed its default from A to B in version Y".
Modify lib/source_monitor.rb:
In the configure method (around line 198), add config.check_deprecations! after yield config and before ModelExtensions.reload!:
def configure
yield config
config.check_deprecations!
SourceMonitor::ModelExtensions.reload!
end
Also in the reset_configuration! method, add DeprecationRegistry.clear! call to ensure test isolation works when resetting config:
Actually, NO -- clear! should NOT be called on reset_configuration. The registry is global engine state (deprecations are registered once at boot), not per-configuration-instance state. Clearing on reset would break the deprecation framework. Instead, test isolation for registry tests should call DeprecationRegistry.clear! in their own teardown.
So the only change to reset_configuration! is: nothing. Leave it as-is.
Summary of changes:
configuration.rb: Add require for deprecation_registry, add check_deprecations! methodsource_monitor.rb: Add config.check_deprecations! in configure method after yield
</action>
Module nesting: SourceMonitor::Configuration::DeprecationRegistryTest < ActiveSupport::TestCase.
Setup/teardown:
setup do
SourceMonitor.reset_configuration!
DeprecationRegistry.clear!
end
teardown do
DeprecationRegistry.clear!
SourceMonitor.reset_configuration!
end
Tests to write (10 tests covering all branches):
"register stores entry in registry" -- Register a synthetic deprecation "http.old_proxy_url". Assert DeprecationRegistry.registered?("http.old_proxy_url") is true. Assert DeprecationRegistry.entries has one entry with correct attributes.
"warning severity logs deprecation and forwards to replacement" -- Register "http.old_proxy_url" with severity: :warning, replacement: "http.proxy", removed_in: "0.5.0". Use a mock or string IO to capture Rails.logger.warn output. Call SourceMonitor.configure { |c| c.http.old_proxy_url = "socks5://localhost" }. Assert the warning message was logged (contains "DEPRECATION", "old_proxy_url", "0.5.0", "http.proxy"). Assert SourceMonitor.config.http.proxy equals "socks5://localhost" (forwarded).
"warning severity reader forwards to replacement getter" -- After registering and setting via the deprecated writer (as in test 2), read config.http.old_proxy_url and assert it returns the same value as config.http.proxy.
"error severity raises DeprecatedOptionError on write" -- Register "http.removed_option" with severity: :error, removed_in: "0.5.0", message: "This option was removed. Use X instead." Assert that SourceMonitor.configure { |c| c.http.removed_option = "value" } raises SourceMonitor::DeprecatedOptionError with message containing "removed_option" and "0.5.0".
"error severity raises DeprecatedOptionError on read" -- Register same as test 4. Assert that calling SourceMonitor.config.http.removed_option raises SourceMonitor::DeprecatedOptionError.
"clear removes defined methods and entries" -- Register a deprecation. Assert registered. Call clear!. Assert NOT registered. Assert entries is empty. Assert SourceMonitor.config.http does NOT respond_to the deprecated method name.
"top-level option deprecation works" -- Register "old_queue_prefix" (no dot -- targets Configuration directly) with severity: :warning, replacement: "queue_namespace". Configure with config.old_queue_prefix = "my_app". Assert warning logged. Assert config.queue_namespace equals "my_app".
"no warnings for valid current configuration" (zero false positives criterion) -- Register a deprecation for a synthetic option. Then configure using ONLY valid current options (e.g. config.http.timeout = 30). Assert NO deprecation warnings were logged.
"multiple deprecations can coexist" -- Register two deprecations on different settings classes. Trigger both in one configure block. Assert both warnings logged.
"check_deprecations! is called during configure" -- Use a mock to verify that check_deprecations! is called. This validates the wiring from task 2.
For capturing Rails.logger.warn output, use:
log_output = StringIO.new
original_logger = Rails.logger
Rails.logger = ActiveSupport::Logger.new(log_output)
# ... run configure ...
Rails.logger = original_logger
assert_match(/DEPRECATION/, log_output.string)
All 10 tests should pass. RuboCop clean.
</action>
<verify>
Run PARALLEL_WORKERS=1 bin/rails test test/lib/source_monitor/configuration/deprecation_registry_test.rb -- all 10 tests pass with 0 failures. Run bin/rubocop test/lib/source_monitor/configuration/deprecation_registry_test.rb -- 0 offenses.
</verify>
<done>
10 tests pass covering: registration, warning with forwarding, warning reader, error on write, error on read, clear cleanup, top-level option, zero false positives, multiple coexistence, and configure wiring. RuboCop clean.
</done>
</task>
<task type="auto">
<name>full-suite-verification-and-brakeman</name>
<files>
</files>
<action>
Run the full verification suite to confirm no regressions and all quality gates pass.
bin/rails test -- full test suite passes with 992+ runs, 0 failures (the 10 new deprecation tests + existing 992)bin/rubocop -- 0 offenses across all filesbin/brakeman --no-pager -- 0 warningsIf any failures:
After all gates pass, confirm:
grep -rn 'class DeprecationRegistry' lib/ returns the registry filegrep -rn 'check_deprecations!' lib/source_monitor/configuration.rb returns the methodgrep -rn 'check_deprecations!' lib/source_monitor.rb returns the configure hookgrep -rn 'DeprecatedOptionError' lib/ returns the error class definitionbin/rails test exits 0 with 1002+ runs, 0 failures. bin/rubocop exits 0. bin/brakeman --no-pager exits 0. All grep checks return matches.
</verify>
<done>
Full suite green with 1002+ runs. RuboCop clean. Brakeman clean. All Phase 2 success criteria met. REQ-28 implemented: deprecation registry with DSL, boot-time warnings for :warning severity, errors for :error severity, zero false positives on current valid config.
</done>
</task>