From 640f125728019c51813b932b4f35e58c671b455d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:07:40 -0600 Subject: [PATCH 01/20] Add parallel CI support for DeprecationTracker Add node_index option to support running deprecation tracking across parallel CI nodes. Each node writes to a shard file (e.g., *.node-0.json) in save mode, and compares only its own buckets in compare mode. Also adds default values for shitlist_path and mode, and a detect_node_index class method that auto-detects common CI env vars (CircleCI, Buildkite, Semaphore, GitLab CI). --- lib/deprecation_tracker.rb | 70 ++++++++++++---- spec/deprecation_tracker_spec.rb | 133 +++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 15 deletions(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index 47700c8..a20c8b7 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -73,11 +73,29 @@ def Kernel.warn(*args, &block) end end + DEFAULT_SHITLIST_PATH = "spec/support/deprecation_warning.shitlist.json" + + CI_NODE_ENV_VARS = %w[ + CIRCLE_NODE_INDEX + BUILDKITE_PARALLEL_JOB + SEMAPHORE_JOB_INDEX + CI_NODE_INDEX + ].freeze + + def self.detect_node_index + CI_NODE_ENV_VARS.each do |var| + value = ENV[var] + return value if value + end + nil + end + def self.init_tracker(opts = {}) - shitlist_path = opts[:shitlist_path] - mode = opts[:mode] + shitlist_path = opts[:shitlist_path] || DEFAULT_SHITLIST_PATH + mode = opts[:mode] || ENV["DEPRECATION_TRACKER"] transform_message = opts[:transform_message] - deprecation_tracker = DeprecationTracker.new(shitlist_path, transform_message, mode) + node_index = opts[:node_index] + deprecation_tracker = DeprecationTracker.new(shitlist_path, transform_message, mode, node_index: node_index) # Since Rails 7.1 the preferred way to track deprecations is to use the deprecation trackers via # `Rails.application.deprecators`. # We fallback to tracking deprecations via the ActiveSupport singleton object if Rails.application.deprecators is @@ -126,13 +144,22 @@ def self.track_minitest(opts = {}) ActiveSupport::TestCase.include(MinitestExtension.new(tracker)) end - attr_reader :deprecation_messages, :shitlist_path, :transform_message, :bucket, :mode + attr_reader :deprecation_messages, :shitlist_path, :transform_message, :bucket, :mode, :node_index - def initialize(shitlist_path, transform_message = nil, mode = :save) + def initialize(shitlist_path, transform_message = nil, mode = :save, node_index: nil) @shitlist_path = shitlist_path @transform_message = transform_message || -> (message) { message } @deprecation_messages = {} @mode = mode.to_sym + @node_index = node_index + end + + def parallel? + !@node_index.nil? + end + + def shard_path + "#{shitlist_path.chomp('.json')}.node-#{node_index}.json" end def add(message) @@ -158,7 +185,14 @@ def compare shitlist = read_shitlist changed_buckets = [] - normalized_deprecation_messages.each do |bucket, messages| + buckets_to_check = if parallel? + # In parallel mode, only check buckets that this node actually ran + normalized_deprecation_messages.select { |bucket, _| deprecation_messages.key?(bucket) } + else + normalized_deprecation_messages + end + + buckets_to_check.each do |bucket, messages| if shitlist[bucket] != messages changed_buckets << bucket end @@ -196,14 +230,15 @@ def diff def save new_shitlist = create_temp_shitlist - create_if_shitlist_path_does_not_exist - FileUtils.cp(new_shitlist.path, shitlist_path) + target_path = parallel? ? shard_path : shitlist_path + create_if_path_does_not_exist(target_path) + FileUtils.cp(new_shitlist.path, target_path) ensure new_shitlist.delete if new_shitlist end - def create_if_shitlist_path_does_not_exist - dirname = File.dirname(shitlist_path) + def create_if_path_does_not_exist(path) + dirname = File.dirname(path) unless File.directory?(dirname) FileUtils.mkdir_p(dirname) end @@ -219,7 +254,12 @@ def create_temp_shitlist # Normalize deprecation messages to reduce noise from file output and test files to be tracked with separate test runs def normalized_deprecation_messages - normalized = read_shitlist.merge(deprecation_messages).each_with_object({}) do |(bucket, messages), hash| + stored = if parallel? && mode == :save + read_shitlist(shard_path) + else + read_shitlist + end + normalized = stored.merge(deprecation_messages).each_with_object({}) do |(bucket, messages), hash| hash[bucket] = messages.sort end @@ -231,10 +271,10 @@ def normalized_deprecation_messages end end - def read_shitlist - return {} unless File.exist?(shitlist_path) - JSON.parse(File.read(shitlist_path)) + def read_shitlist(path = shitlist_path) + return {} unless File.exist?(path) + JSON.parse(File.read(path)) rescue JSON::ParserError => e - raise "#{shitlist_path} is not valid JSON: #{e.message}" + raise "#{path} is not valid JSON: #{e.message}" end end diff --git a/spec/deprecation_tracker_spec.rb b/spec/deprecation_tracker_spec.rb index 330723e..f0c3c2a 100644 --- a/spec/deprecation_tracker_spec.rb +++ b/spec/deprecation_tracker_spec.rb @@ -293,6 +293,139 @@ def self.behavior end end + describe ".detect_node_index" do + it "returns nil when no CI env vars are set" do + CI_NODE_ENV_VARS = DeprecationTracker::CI_NODE_ENV_VARS + CI_NODE_ENV_VARS.each { |var| ENV.delete(var) } + + expect(DeprecationTracker.detect_node_index).to be_nil + end + + it "detects CIRCLE_NODE_INDEX" do + stub_const("ENV", ENV.to_h.merge("CIRCLE_NODE_INDEX" => "2")) + expect(DeprecationTracker.detect_node_index).to eq("2") + end + + it "detects BUILDKITE_PARALLEL_JOB" do + stub_const("ENV", ENV.to_h.merge("BUILDKITE_PARALLEL_JOB" => "1")) + expect(DeprecationTracker.detect_node_index).to eq("1") + end + + it "returns the first matching env var" do + stub_const("ENV", ENV.to_h.merge( + "CIRCLE_NODE_INDEX" => "0", + "CI_NODE_INDEX" => "3" + )) + expect(DeprecationTracker.detect_node_index).to eq("0") + end + end + + describe "#parallel?" do + it "returns false when node_index is nil" do + tracker = DeprecationTracker.new(shitlist_path) + expect(tracker.parallel?).to be false + end + + it "returns true when node_index is set" do + tracker = DeprecationTracker.new(shitlist_path, nil, :save, node_index: "0") + expect(tracker.parallel?).to be true + end + end + + describe "#shard_path" do + it "derives shard path from shitlist_path and node_index" do + tracker = DeprecationTracker.new("spec/support/deprecation_warning.shitlist.json", nil, :save, node_index: "2") + expect(tracker.shard_path).to eq("spec/support/deprecation_warning.shitlist.node-2.json") + end + + it "works with custom shitlist paths" do + tracker = DeprecationTracker.new("tmp/my_custom_shitlist.json", nil, :save, node_index: "0") + expect(tracker.shard_path).to eq("tmp/my_custom_shitlist.node-0.json") + end + end + + describe "parallel mode" do + describe "#save" do + it "writes to shard path when node_index is set" do + tracker = DeprecationTracker.new(shitlist_path, nil, :save, node_index: "0") + tracker.bucket = "bucket 1" + tracker.add("a") + tracker.save + + expected_shard = "#{shitlist_path.chomp('.json')}.node-0.json" + expect(File.exist?(expected_shard)).to be true + expect(JSON.parse(File.read(expected_shard))).to eq("bucket 1" => ["a"]) + expect(File.exist?(shitlist_path)).to be false + + FileUtils.rm(expected_shard) + end + + it "merges with existing shard data on subsequent saves" do + shard = "#{shitlist_path.chomp('.json')}.node-0.json" + + tracker1 = DeprecationTracker.new(shitlist_path, nil, :save, node_index: "0") + tracker1.bucket = "bucket 1" + tracker1.add("a") + tracker1.save + + tracker2 = DeprecationTracker.new(shitlist_path, nil, :save, node_index: "0") + tracker2.bucket = "bucket 2" + tracker2.add("b") + tracker2.save + + data = JSON.parse(File.read(shard)) + expect(data).to eq("bucket 1" => ["a"], "bucket 2" => ["b"]) + + FileUtils.rm(shard) + end + end + + describe "#compare" do + it "only checks buckets that this node ran" do + # Set up canonical shitlist with two buckets + setup_tracker = DeprecationTracker.new(shitlist_path) + setup_tracker.bucket = "bucket 1" + setup_tracker.add("a") + setup_tracker.bucket = "bucket 2" + setup_tracker.add("b") + setup_tracker.save + + # Parallel node only runs bucket 2 with matching deprecations + tracker = DeprecationTracker.new(shitlist_path, nil, :compare, node_index: "0") + tracker.bucket = "bucket 2" + tracker.add("b") + + expect { tracker.compare }.not_to raise_error + end + + it "raises when this node's buckets have changed" do + setup_tracker = DeprecationTracker.new(shitlist_path) + setup_tracker.bucket = "bucket 1" + setup_tracker.add("a") + setup_tracker.save + + tracker = DeprecationTracker.new(shitlist_path, nil, :compare, node_index: "0") + tracker.bucket = "bucket 1" + tracker.add("different") + + expect { tracker.compare }.to raise_error(DeprecationTracker::UnexpectedDeprecations) + end + end + end + + describe "default values" do + it "uses default shitlist_path when not provided" do + tracker = DeprecationTracker.init_tracker(mode: "save") + expect(tracker.shitlist_path).to eq("spec/support/deprecation_warning.shitlist.json") + end + + it "uses ENV['DEPRECATION_TRACKER'] as default mode" do + stub_const("ENV", ENV.to_h.merge("DEPRECATION_TRACKER" => "compare")) + tracker = DeprecationTracker.init_tracker({}) + expect(tracker.mode).to eq(:compare) + end + end + describe DeprecationTracker::KernelWarnTracker do before { DeprecationTracker::KernelWarnTracker.callbacks.clear } From af5cdb5675bfface9743e962e2c5087b530ad93d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:14:42 -0600 Subject: [PATCH 02/20] Fall back to :save when mode is not provided --- lib/deprecation_tracker.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index a20c8b7..7d27a05 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -92,7 +92,7 @@ def self.detect_node_index def self.init_tracker(opts = {}) shitlist_path = opts[:shitlist_path] || DEFAULT_SHITLIST_PATH - mode = opts[:mode] || ENV["DEPRECATION_TRACKER"] + mode = opts[:mode] || ENV["DEPRECATION_TRACKER"] || :save transform_message = opts[:transform_message] node_index = opts[:node_index] deprecation_tracker = DeprecationTracker.new(shitlist_path, transform_message, mode, node_index: node_index) From 81b1c8bbfb78aa6d87eb3972ef951007202d98c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:25:48 -0600 Subject: [PATCH 03/20] Remove detect_node_index auto-detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users know their CI env vars — they can pass node_index directly (e.g., node_index: ENV["CIRCLE_NODE_INDEX"]). --- lib/deprecation_tracker.rb | 15 --------------- spec/deprecation_tracker_spec.rb | 27 --------------------------- 2 files changed, 42 deletions(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index 7d27a05..f9c65a7 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -75,21 +75,6 @@ def Kernel.warn(*args, &block) DEFAULT_SHITLIST_PATH = "spec/support/deprecation_warning.shitlist.json" - CI_NODE_ENV_VARS = %w[ - CIRCLE_NODE_INDEX - BUILDKITE_PARALLEL_JOB - SEMAPHORE_JOB_INDEX - CI_NODE_INDEX - ].freeze - - def self.detect_node_index - CI_NODE_ENV_VARS.each do |var| - value = ENV[var] - return value if value - end - nil - end - def self.init_tracker(opts = {}) shitlist_path = opts[:shitlist_path] || DEFAULT_SHITLIST_PATH mode = opts[:mode] || ENV["DEPRECATION_TRACKER"] || :save diff --git a/spec/deprecation_tracker_spec.rb b/spec/deprecation_tracker_spec.rb index f0c3c2a..f86d089 100644 --- a/spec/deprecation_tracker_spec.rb +++ b/spec/deprecation_tracker_spec.rb @@ -293,33 +293,6 @@ def self.behavior end end - describe ".detect_node_index" do - it "returns nil when no CI env vars are set" do - CI_NODE_ENV_VARS = DeprecationTracker::CI_NODE_ENV_VARS - CI_NODE_ENV_VARS.each { |var| ENV.delete(var) } - - expect(DeprecationTracker.detect_node_index).to be_nil - end - - it "detects CIRCLE_NODE_INDEX" do - stub_const("ENV", ENV.to_h.merge("CIRCLE_NODE_INDEX" => "2")) - expect(DeprecationTracker.detect_node_index).to eq("2") - end - - it "detects BUILDKITE_PARALLEL_JOB" do - stub_const("ENV", ENV.to_h.merge("BUILDKITE_PARALLEL_JOB" => "1")) - expect(DeprecationTracker.detect_node_index).to eq("1") - end - - it "returns the first matching env var" do - stub_const("ENV", ENV.to_h.merge( - "CIRCLE_NODE_INDEX" => "0", - "CI_NODE_INDEX" => "3" - )) - expect(DeprecationTracker.detect_node_index).to eq("0") - end - end - describe "#parallel?" do it "returns false when node_index is nil" do tracker = DeprecationTracker.new(shitlist_path) From 400720a02cd472b4cdff57734c83f7a1062bca4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:29:36 -0600 Subject: [PATCH 04/20] Extract target_path method for write path resolution --- lib/deprecation_tracker.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index f9c65a7..49977ea 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -147,6 +147,10 @@ def shard_path "#{shitlist_path.chomp('.json')}.node-#{node_index}.json" end + def target_path + parallel? ? shard_path : shitlist_path + end + def add(message) return if bucket.nil? @@ -215,7 +219,6 @@ def diff def save new_shitlist = create_temp_shitlist - target_path = parallel? ? shard_path : shitlist_path create_if_path_does_not_exist(target_path) FileUtils.cp(new_shitlist.path, target_path) ensure @@ -239,8 +242,8 @@ def create_temp_shitlist # Normalize deprecation messages to reduce noise from file output and test files to be tracked with separate test runs def normalized_deprecation_messages - stored = if parallel? && mode == :save - read_shitlist(shard_path) + stored = if mode == :save + read_shitlist(target_path) else read_shitlist end From d384360090e666beb5941f075d2b9fd59eee8072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:32:22 -0600 Subject: [PATCH 05/20] Simplify normalized_deprecation_messages to always use target_path --- lib/deprecation_tracker.rb | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index 49977ea..2f3297c 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -242,12 +242,7 @@ def create_temp_shitlist # Normalize deprecation messages to reduce noise from file output and test files to be tracked with separate test runs def normalized_deprecation_messages - stored = if mode == :save - read_shitlist(target_path) - else - read_shitlist - end - normalized = stored.merge(deprecation_messages).each_with_object({}) do |(bucket, messages), hash| + normalized = read_shitlist(target_path).merge(deprecation_messages).each_with_object({}) do |(bucket, messages), hash| hash[bucket] = messages.sort end From 638834ff5f65d2dd0e72d0cad1dcc83d11e67a4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:36:38 -0600 Subject: [PATCH 06/20] Rename read_shitlist to read_json --- lib/deprecation_tracker.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index 2f3297c..c63e9d1 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -171,7 +171,7 @@ def after_run end def compare - shitlist = read_shitlist + shitlist = read_json changed_buckets = [] buckets_to_check = if parallel? @@ -242,7 +242,7 @@ def create_temp_shitlist # Normalize deprecation messages to reduce noise from file output and test files to be tracked with separate test runs def normalized_deprecation_messages - normalized = read_shitlist(target_path).merge(deprecation_messages).each_with_object({}) do |(bucket, messages), hash| + normalized = read_json(target_path).merge(deprecation_messages).each_with_object({}) do |(bucket, messages), hash| hash[bucket] = messages.sort end @@ -254,7 +254,7 @@ def normalized_deprecation_messages end end - def read_shitlist(path = shitlist_path) + def read_json(path = shitlist_path) return {} unless File.exist?(path) JSON.parse(File.read(path)) rescue JSON::ParserError => e From 5fc959421614753774bd6ab3983af41ee4bcb115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:36:53 -0600 Subject: [PATCH 07/20] Rename read_json to read_stored_deprecations --- lib/deprecation_tracker.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index c63e9d1..17a2b3e 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -171,7 +171,7 @@ def after_run end def compare - shitlist = read_json + shitlist = read_stored_deprecations changed_buckets = [] buckets_to_check = if parallel? @@ -242,7 +242,7 @@ def create_temp_shitlist # Normalize deprecation messages to reduce noise from file output and test files to be tracked with separate test runs def normalized_deprecation_messages - normalized = read_json(target_path).merge(deprecation_messages).each_with_object({}) do |(bucket, messages), hash| + normalized = read_stored_deprecations(target_path).merge(deprecation_messages).each_with_object({}) do |(bucket, messages), hash| hash[bucket] = messages.sort end @@ -254,7 +254,7 @@ def normalized_deprecation_messages end end - def read_json(path = shitlist_path) + def read_stored_deprecations(path = shitlist_path) return {} unless File.exist?(path) JSON.parse(File.read(path)) rescue JSON::ParserError => e From 8963b6d4637e2fa9baa077cddc1183f295e4551b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:37:42 -0600 Subject: [PATCH 08/20] Rename shitlist variable to stored in compare method --- lib/deprecation_tracker.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index 17a2b3e..4c9f9da 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -171,7 +171,7 @@ def after_run end def compare - shitlist = read_stored_deprecations + stored = read_stored_deprecations changed_buckets = [] buckets_to_check = if parallel? @@ -182,7 +182,7 @@ def compare end buckets_to_check.each do |bucket, messages| - if shitlist[bucket] != messages + if stored[bucket] != messages changed_buckets << bucket end end From e9ab6deb6df860dee330d0b1fff8bf2b7176bf02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:38:14 -0600 Subject: [PATCH 09/20] Make path argument explicit in read_stored_deprecations --- lib/deprecation_tracker.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index 4c9f9da..5f809e2 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -171,7 +171,7 @@ def after_run end def compare - stored = read_stored_deprecations + stored = read_stored_deprecations(shitlist_path) changed_buckets = [] buckets_to_check = if parallel? @@ -254,7 +254,7 @@ def normalized_deprecation_messages end end - def read_stored_deprecations(path = shitlist_path) + def read_stored_deprecations(path) return {} unless File.exist?(path) JSON.parse(File.read(path)) rescue JSON::ParserError => e From 1066282e808907386c6ba96041094e2283934ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:41:33 -0600 Subject: [PATCH 10/20] Rename create_temp_shitlist to create_temp_file --- lib/deprecation_tracker.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index 5f809e2..5234055 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -211,14 +211,14 @@ def compare end def diff - new_shitlist = create_temp_shitlist + new_shitlist = create_temp_file `git diff --no-index #{shitlist_path} #{new_shitlist.path}` ensure new_shitlist.delete end def save - new_shitlist = create_temp_shitlist + new_shitlist = create_temp_file create_if_path_does_not_exist(target_path) FileUtils.cp(new_shitlist.path, target_path) ensure @@ -232,7 +232,7 @@ def create_if_path_does_not_exist(path) end end - def create_temp_shitlist + def create_temp_file temp_file = Tempfile.new("temp-deprecation-tracker-shitlist") temp_file.write(JSON.pretty_generate(normalized_deprecation_messages)) temp_file.flush From c0937e725e5c6cfbd3302b48a83bc5c649f9329c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:42:27 -0600 Subject: [PATCH 11/20] Rename new_shitlist variable to temp_file --- lib/deprecation_tracker.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index 5234055..a2ffcb1 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -211,18 +211,18 @@ def compare end def diff - new_shitlist = create_temp_file - `git diff --no-index #{shitlist_path} #{new_shitlist.path}` + temp_file = create_temp_file + `git diff --no-index #{shitlist_path} #{temp_file.path}` ensure - new_shitlist.delete + temp_file.delete end def save - new_shitlist = create_temp_file + temp_file = create_temp_file create_if_path_does_not_exist(target_path) - FileUtils.cp(new_shitlist.path, target_path) + FileUtils.cp(temp_file.path, target_path) ensure - new_shitlist.delete if new_shitlist + temp_file.delete if temp_file end def create_if_path_does_not_exist(path) From 100a59ff845f413d9911f723e202522e29a1156f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:42:47 -0600 Subject: [PATCH 12/20] Rename read_stored_deprecations to read_json --- lib/deprecation_tracker.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index a2ffcb1..a7ac332 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -171,7 +171,7 @@ def after_run end def compare - stored = read_stored_deprecations(shitlist_path) + stored = read_json(shitlist_path) changed_buckets = [] buckets_to_check = if parallel? @@ -242,7 +242,7 @@ def create_temp_file # Normalize deprecation messages to reduce noise from file output and test files to be tracked with separate test runs def normalized_deprecation_messages - normalized = read_stored_deprecations(target_path).merge(deprecation_messages).each_with_object({}) do |(bucket, messages), hash| + normalized = read_json(target_path).merge(deprecation_messages).each_with_object({}) do |(bucket, messages), hash| hash[bucket] = messages.sort end @@ -254,7 +254,7 @@ def normalized_deprecation_messages end end - def read_stored_deprecations(path) + def read_json(path) return {} unless File.exist?(path) JSON.parse(File.read(path)) rescue JSON::ParserError => e From 904b2f70f77aa50584dd4d0639a57306439b177d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:54:14 -0600 Subject: [PATCH 13/20] Fix NoMethodError when mode is nil in constructor The constructor calls mode.to_sym which crashes with NoMethodError when mode is nil. This happens when calling DeprecationTracker.new directly without a mode argument (e.g., DeprecationTracker.new("path", nil, nil)). The init_tracker method has its own fallback, but the constructor is public and should handle nil gracefully. Now falls back to :save when mode is nil, matching the documented default behavior. --- lib/deprecation_tracker.rb | 2 +- spec/deprecation_tracker_spec.rb | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index a7ac332..49fe016 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -135,7 +135,7 @@ def initialize(shitlist_path, transform_message = nil, mode = :save, node_index: @shitlist_path = shitlist_path @transform_message = transform_message || -> (message) { message } @deprecation_messages = {} - @mode = mode.to_sym + @mode = mode ? mode.to_sym : :save @node_index = node_index end diff --git a/spec/deprecation_tracker_spec.rb b/spec/deprecation_tracker_spec.rb index f86d089..df325d5 100644 --- a/spec/deprecation_tracker_spec.rb +++ b/spec/deprecation_tracker_spec.rb @@ -243,6 +243,11 @@ tracker.after_run end + it "defaults to save mode when mode is nil" do + tracker = DeprecationTracker.new(shitlist_path, nil, nil) + expect(tracker.mode).to eq(:save) + end + it "does not save nor compare if mode is invalid" do tracker = DeprecationTracker.new(shitlist_path, nil, "random_stuff") expect(tracker).not_to receive(:save) From 46aaa6b6da2a8879d305301322d7511cf40d6bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:55:25 -0600 Subject: [PATCH 14/20] Memoize normalized_deprecation_messages to avoid repeated disk reads In compare mode, normalized_deprecation_messages was called multiple times: once in compare to build buckets_to_check, and again in diff via create_temp_file. Each call triggered a read_json disk read and JSON parse of the same file. Since this method is only called after the test run completes (via after_run), the data cannot change between calls. Memoizing it eliminates redundant file I/O. --- lib/deprecation_tracker.rb | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index 49fe016..68d3f82 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -242,14 +242,16 @@ def create_temp_file # Normalize deprecation messages to reduce noise from file output and test files to be tracked with separate test runs def normalized_deprecation_messages - normalized = read_json(target_path).merge(deprecation_messages).each_with_object({}) do |(bucket, messages), hash| - hash[bucket] = messages.sort - end + @normalized_deprecation_messages ||= begin + normalized = read_json(target_path).merge(deprecation_messages).each_with_object({}) do |(bucket, messages), hash| + hash[bucket] = messages.sort + end - # not using `to_h` here to support older ruby versions - {}.tap do |h| - normalized.reject {|_key, value| value.empty? }.sort_by {|key, _value| key }.each do |k ,v| - h[k] = v + # not using `to_h` here to support older ruby versions + {}.tap do |h| + normalized.reject {|_key, value| value.empty? }.sort_by {|key, _value| key }.each do |k ,v| + h[k] = v + end end end end From 4ba8b41f133d690b5f690b22954df4a4db234317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:56:43 -0600 Subject: [PATCH 15/20] Fix shell injection in diff method for paths with spaces The diff method interpolates shitlist_path directly into a backtick shell command. File paths containing spaces or special characters cause git diff to fail silently, producing an empty diff in the error message. Now uses Shellwords.shellescape to properly quote both file paths before passing them to the shell. --- lib/deprecation_tracker.rb | 3 ++- spec/deprecation_tracker_spec.rb | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index 68d3f82..047670a 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -1,5 +1,6 @@ require "rainbow" require "json" +require "shellwords" # A shitlist for deprecation warnings during test runs. It has two modes: "save" and "compare" # @@ -212,7 +213,7 @@ def compare def diff temp_file = create_temp_file - `git diff --no-index #{shitlist_path} #{temp_file.path}` + `git diff --no-index #{Shellwords.shellescape(shitlist_path)} #{Shellwords.shellescape(temp_file.path)}` ensure temp_file.delete end diff --git a/spec/deprecation_tracker_spec.rb b/spec/deprecation_tracker_spec.rb index df325d5..0cf7a85 100644 --- a/spec/deprecation_tracker_spec.rb +++ b/spec/deprecation_tracker_spec.rb @@ -72,6 +72,25 @@ expect { subject.compare }.not_to raise_error end + it "works with file paths that contain spaces" do + dir_with_spaces = "/tmp/path with spaces" + FileUtils.mkdir_p(dir_with_spaces) + path = "#{dir_with_spaces}/shitlist.json" + + setup_tracker = DeprecationTracker.new(path) + setup_tracker.bucket = "bucket 1" + setup_tracker.add("a") + setup_tracker.save + + subject = DeprecationTracker.new(path) + subject.bucket = "bucket 1" + subject.add("b") + + expect { subject.compare }.to raise_error(DeprecationTracker::UnexpectedDeprecations, /diff/) + ensure + FileUtils.rm_rf(dir_with_spaces) + end + it "raises an error when recorded messages are different for a given bucket" do setup_tracker = DeprecationTracker.new(shitlist_path) setup_tracker.bucket = "bucket 1" From 7f4e9e1a61a8d7bbd7a7b3ba4ad5141422bd6271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:57:05 -0600 Subject: [PATCH 16/20] Rename DEFAULT_SHITLIST_PATH to DEFAULT_PATH Consistent with the other renames in this branch that moved away from the "shitlist" naming in method and variable names. --- lib/deprecation_tracker.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index 047670a..536a4ae 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -74,10 +74,10 @@ def Kernel.warn(*args, &block) end end - DEFAULT_SHITLIST_PATH = "spec/support/deprecation_warning.shitlist.json" + DEFAULT_PATH = "spec/support/deprecation_warning.shitlist.json" def self.init_tracker(opts = {}) - shitlist_path = opts[:shitlist_path] || DEFAULT_SHITLIST_PATH + shitlist_path = opts[:shitlist_path] || DEFAULT_PATH mode = opts[:mode] || ENV["DEPRECATION_TRACKER"] || :save transform_message = opts[:transform_message] node_index = opts[:node_index] From 261b60d4fb0be0496ccfd6a2b4c229e842e99867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 01:57:32 -0600 Subject: [PATCH 17/20] Use idiomatic any? instead of length > 0 Array#any? has been available since Ruby 1.8 and is the idiomatic way to check for a non-empty collection in Ruby. --- lib/deprecation_tracker.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index 536a4ae..277babb 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -188,7 +188,7 @@ def compare end end - if changed_buckets.length > 0 + if changed_buckets.any? message = <<-MESSAGE ⚠️ Deprecation warnings have changed! From 0d27b0d0ee71340791269f89fd9ac4e73a60f1aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 02:00:49 -0600 Subject: [PATCH 18/20] Fix Ruby 2.4 syntax error in spaces test The ensure keyword inside a block (do...end) without an explicit begin is only supported in Ruby 2.5+. Wrap with begin/ensure/end for compatibility with Ruby 2.4 and older. --- spec/deprecation_tracker_spec.rb | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/spec/deprecation_tracker_spec.rb b/spec/deprecation_tracker_spec.rb index 0cf7a85..cea7b4c 100644 --- a/spec/deprecation_tracker_spec.rb +++ b/spec/deprecation_tracker_spec.rb @@ -74,21 +74,23 @@ it "works with file paths that contain spaces" do dir_with_spaces = "/tmp/path with spaces" - FileUtils.mkdir_p(dir_with_spaces) - path = "#{dir_with_spaces}/shitlist.json" + begin + FileUtils.mkdir_p(dir_with_spaces) + path = "#{dir_with_spaces}/shitlist.json" - setup_tracker = DeprecationTracker.new(path) - setup_tracker.bucket = "bucket 1" - setup_tracker.add("a") - setup_tracker.save + setup_tracker = DeprecationTracker.new(path) + setup_tracker.bucket = "bucket 1" + setup_tracker.add("a") + setup_tracker.save - subject = DeprecationTracker.new(path) - subject.bucket = "bucket 1" - subject.add("b") + subject = DeprecationTracker.new(path) + subject.bucket = "bucket 1" + subject.add("b") - expect { subject.compare }.to raise_error(DeprecationTracker::UnexpectedDeprecations, /diff/) - ensure - FileUtils.rm_rf(dir_with_spaces) + expect { subject.compare }.to raise_error(DeprecationTracker::UnexpectedDeprecations, /diff/) + ensure + FileUtils.rm_rf(dir_with_spaces) + end end it "raises an error when recorded messages are different for a given bucket" do From 9cde4c04cc382a66c96c84e7c362bd02f1c91965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 02:05:42 -0600 Subject: [PATCH 19/20] Revert Shellwords fix for paths with spaces Removing the Shellwords change and its test to keep this PR focused on parallel CI support and renames. The spaces-in-path fix can be addressed in a separate PR. --- lib/deprecation_tracker.rb | 3 +-- spec/deprecation_tracker_spec.rb | 21 --------------------- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index 277babb..519a4db 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -1,6 +1,5 @@ require "rainbow" require "json" -require "shellwords" # A shitlist for deprecation warnings during test runs. It has two modes: "save" and "compare" # @@ -213,7 +212,7 @@ def compare def diff temp_file = create_temp_file - `git diff --no-index #{Shellwords.shellescape(shitlist_path)} #{Shellwords.shellescape(temp_file.path)}` + `git diff --no-index #{shitlist_path} #{temp_file.path}` ensure temp_file.delete end diff --git a/spec/deprecation_tracker_spec.rb b/spec/deprecation_tracker_spec.rb index cea7b4c..df325d5 100644 --- a/spec/deprecation_tracker_spec.rb +++ b/spec/deprecation_tracker_spec.rb @@ -72,27 +72,6 @@ expect { subject.compare }.not_to raise_error end - it "works with file paths that contain spaces" do - dir_with_spaces = "/tmp/path with spaces" - begin - FileUtils.mkdir_p(dir_with_spaces) - path = "#{dir_with_spaces}/shitlist.json" - - setup_tracker = DeprecationTracker.new(path) - setup_tracker.bucket = "bucket 1" - setup_tracker.add("a") - setup_tracker.save - - subject = DeprecationTracker.new(path) - subject.bucket = "bucket 1" - subject.add("b") - - expect { subject.compare }.to raise_error(DeprecationTracker::UnexpectedDeprecations, /diff/) - ensure - FileUtils.rm_rf(dir_with_spaces) - end - end - it "raises an error when recorded messages are different for a given bucket" do setup_tracker = DeprecationTracker.new(shitlist_path) setup_tracker.bucket = "bucket 1" From f8c203d348e6964f6a0c87340b3849297f5190bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Thu, 2 Apr 2026 02:08:16 -0600 Subject: [PATCH 20/20] Use File.extname for shard_path extension handling Previously hardcoded .json in chomp/append, which would produce incorrect shard paths for files with non-.json extensions. Now uses File.extname to handle any file extension correctly. --- lib/deprecation_tracker.rb | 3 ++- spec/deprecation_tracker_spec.rb | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/deprecation_tracker.rb b/lib/deprecation_tracker.rb index 519a4db..8f03a3e 100644 --- a/lib/deprecation_tracker.rb +++ b/lib/deprecation_tracker.rb @@ -144,7 +144,8 @@ def parallel? end def shard_path - "#{shitlist_path.chomp('.json')}.node-#{node_index}.json" + ext = File.extname(shitlist_path) + "#{shitlist_path.chomp(ext)}.node-#{node_index}#{ext}" end def target_path diff --git a/spec/deprecation_tracker_spec.rb b/spec/deprecation_tracker_spec.rb index df325d5..24fd311 100644 --- a/spec/deprecation_tracker_spec.rb +++ b/spec/deprecation_tracker_spec.rb @@ -330,7 +330,8 @@ def self.behavior tracker.add("a") tracker.save - expected_shard = "#{shitlist_path.chomp('.json')}.node-0.json" + ext = File.extname(shitlist_path) + expected_shard = "#{shitlist_path.chomp(ext)}.node-0#{ext}" expect(File.exist?(expected_shard)).to be true expect(JSON.parse(File.read(expected_shard))).to eq("bucket 1" => ["a"]) expect(File.exist?(shitlist_path)).to be false @@ -339,7 +340,8 @@ def self.behavior end it "merges with existing shard data on subsequent saves" do - shard = "#{shitlist_path.chomp('.json')}.node-0.json" + ext = File.extname(shitlist_path) + shard = "#{shitlist_path.chomp(ext)}.node-0#{ext}" tracker1 = DeprecationTracker.new(shitlist_path, nil, :save, node_index: "0") tracker1.bucket = "bucket 1"