deprecool 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8979f5deb74f90a5e9e6061319b79a1d920c6153bf3a32d378293514c6f5c547
4
+ data.tar.gz: 7a17ed36ecfb88aab21b1032a1fa74ae2165e8126826bae9cff2a341c5ae47a7
5
+ SHA512:
6
+ metadata.gz: d61efb1f6c1a4b36570040aa91dbf20b856fa63b8564390732e3ef14de6ee704f899d6aace163c338db6bec7511f2d189e158d5819f80b75a9002709a1214c4c
7
+ data.tar.gz: 62ada454736be50a5ff349562d2500d8266ecbe23829cd5a078446abd768fb7677e21e676201da08c4b6f9e1e6329afa0a5a3e3c4b2891c0355777de10166bd4
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Deprecool
2
+
3
+ ## [0.1.0] - 2026-08-13
4
+
5
+ - Initial Release
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Zac Radford
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # Deprecool
2
+
3
+ A static ruby code analyzer to find your deprecated code!
4
+
5
+ For any developer who has ever ignored a gem's warning and then ran into an error when they went to update
6
+
7
+ ## Usage
8
+
9
+ Add to your gemfile: `gem 'deprecool'`
10
+ Or directly `gem install deprecool`
11
+
12
+ Then you can run `deprecool` in the command line to get:
13
+
14
+ ```
15
+ Commands:
16
+ deprecool list # Display which finders are used in this version
17
+ deprecool scan [PATHS] # Scan files or directories for known deprecations
18
+ deprecool version # Print the deprecool version
19
+ ```
20
+
21
+ ### Scan
22
+
23
+ `deprecool scan` has several options that can be passed, or it can be run in a folder with a `Gemfile.lock` to automatically look for deprecations based on the currently installed gems.
24
+
25
+ - `--gem` takes a comma separated list of gems to scan for:
26
+
27
+ ```
28
+ deprecool scan --gem=rails,ruby
29
+ ```
30
+ _(make sure there are no spaces between the gem names)_
31
+
32
+ - `--format=json` if you want the output as json instead of the default text
33
+ - `--lockfile` takes a path to your `Gemfile.lock`, it defaults to using the current directory
34
+ - `--all` run every finder regardless of applicability
35
+ - `--paths` an array of the files or directories to look for ruby file in, defaults to `'.'`
data/exe/deprecool ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ lib = File.expand_path('../lib', __dir__)
5
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
+
7
+ require 'deprecool'
8
+
9
+ Deprecool::CLI.start(ARGV)
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'dry/cli'
5
+ require 'debug'
6
+
7
+ module Deprecool
8
+ # Command-line entry point. Scans the given files/directories and reports
9
+ # deprecations.
10
+ module CLI
11
+ module Commands
12
+ extend Dry::CLI::Registry
13
+
14
+ class Version < Dry::CLI::Command
15
+ desc 'Print the deprecool version'
16
+
17
+ def call(*)
18
+ puts "deprecool #{Deprecool::VERSION}"
19
+ end
20
+ end
21
+
22
+ class List < Dry::CLI::Command
23
+ desc 'Display which finders are used in this version'
24
+
25
+ option :gems, type: :array, desc: 'Specify gem name(s) to list which versions have deprecation tracking available'
26
+
27
+ def call(gems:)
28
+ CLI.list_finders(Finder.registry)
29
+
30
+ # TODO: REPL to filter through the available finders?
31
+ end
32
+ end
33
+
34
+ class Scan < Dry::CLI::Command
35
+ desc 'Scan files or directories for known deprecations'
36
+
37
+ argument :paths, type: :array, desc: 'Files or directories to scan'
38
+ option :lockfile, default: 'Gemfile.lock', desc: 'Path to Gemfile.lock, defaults to current directory'
39
+ option :gems, type: :array, desc: 'Gems to scan for: --gems=ruby,rails'
40
+ option :format, default: 'text', values: %w[text json], desc: 'Output format'
41
+ option :all, type: :boolean, default: false, desc: 'Run every finder regardless of version'
42
+
43
+ def call(paths:, format:, gems: [], all:, lockfile:, **)
44
+ json_output = format == 'json'
45
+ files = CLI.ruby_files(paths)
46
+
47
+ if files.empty?
48
+ if paths.empty?
49
+ warn CLI.colorize('deprecool: please supply a path/to/file/or/directory', :red)
50
+ exit 2
51
+ end
52
+
53
+ warn CLI.colorize("deprecool: no Ruby files found in #{paths.join(', ')}", :red)
54
+ exit 2
55
+ end
56
+
57
+ if gems.any?
58
+ puts "Scanning for deprecations from: #{gems.join(', ')}" unless json_output
59
+ # we won't be scanning a lockfile
60
+ gem_versions = []
61
+ elsif all
62
+ gem_versions = []
63
+ gems = []
64
+ else
65
+ puts "Scanning #{lockfile}..." unless json_output
66
+ gem_versions = LockfileParser.parse!(lockfile)
67
+
68
+ # we don't need to look for individual gems when scanning gemfiles
69
+ gems = []
70
+ end
71
+
72
+ finders = Registry.applicable(gems:, gem_versions:, include_all: all)
73
+ scanner = Scanner.new(finders)
74
+ offenses = files.flat_map { |file| scanner.scan_file(file) }
75
+ .sort_by { |offense| [offense.file_path, offense.line, offense.column] }
76
+
77
+ offenses = offenses.group_by(&:id) unless json_output
78
+
79
+ CLI.report(offenses, format, finders)
80
+ exit(offenses.empty? ? 0 : 1)
81
+ end
82
+ end
83
+
84
+ register 'version', Version, aliases: %w[v -v --version]
85
+ register 'scan', Scan
86
+ register 'list', List, aliases: %w[l -l --list]
87
+ end
88
+
89
+ class << self
90
+ def start(argv)
91
+ Dry::CLI.new(Commands).call(arguments: argv)
92
+ end
93
+
94
+ def ruby_files(paths)
95
+ paths = paths.empty? ? ['.'] : paths
96
+
97
+ paths.flat_map do |path|
98
+ if File.directory?(path)
99
+ Dir[File.join(path, '**', '*.rb')]
100
+ elsif File.file?(path)
101
+ [path]
102
+ else
103
+ warn "deprecool: no such file or directory: #{path}"
104
+ []
105
+ end
106
+ end.uniq.sort
107
+ end
108
+
109
+ def report(offenses, format, finders)
110
+ if format == 'json'
111
+ puts JSON.pretty_generate(offenses.map(&:to_h))
112
+ else
113
+ text_report(offenses, finders)
114
+ end
115
+ end
116
+
117
+ def text_report(offenses, finders)
118
+ if offenses.empty?
119
+ puts colorize('No deprecations found.', :green)
120
+ puts "(#{finders.size} finder#{'s' unless finders.size == 1} active)"
121
+ return
122
+ end
123
+
124
+ offense_count = 0
125
+
126
+ offenses.each_value do |offense_array|
127
+ offense = offense_array.first
128
+
129
+ puts "\n#{colorize(offense.title, :bold)}\n"
130
+ puts " * #{offense.summary}"
131
+ puts " #{colorize('fix:', :green)} #{offense.suggestion}" if offense.suggestion
132
+ puts " #{colorize('source:', :green)} #{offense.reference}" if offense.reference
133
+ puts ' Found At:'
134
+ offense_array.each do |o|
135
+ offense_count += 1
136
+ confidence = o.confidence
137
+ confidence_color = confidence == :high ? :red : :yellow
138
+ badge = colorize("(#{confidence} confidence)", confidence_color)
139
+
140
+ puts " (#{offense_count}) #{colorize(o.location, :cyan)} #{badge}"
141
+ puts " #{o.source_line.strip}" if o.source_line
142
+ puts
143
+ end
144
+ end
145
+
146
+ puts colorize("#{offense_count} deprecation#{'s' unless offense_count == 1} found.", :red)
147
+ end
148
+
149
+ def list_finders(finders)
150
+ puts 'Active finders:'
151
+ finders.sort_by { [it.gem.to_s, it.deprecated_in.to_s, it.id.to_s] }.each do
152
+ puts " - #{it.classname.ljust(35)} (#{it.gem} #{it.deprecated_in.to_s}) — #{it.title}"
153
+ end
154
+ puts ' (none)' if finders.empty?
155
+ end
156
+
157
+ def colorize(text, color)
158
+ colors = { red: 31, green: 32, yellow: 33, cyan: 36, bold: 1 }
159
+
160
+ "\e[#{colors.fetch(color, nil)}m#{text}\e[0m"
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ # requiring this here so we don't have to remember to add
4
+ # it in individual finders
5
+ require_relative 'helpers/prism_helpers'
6
+
7
+ module Deprecool
8
+ # Base class for every deprecation finder.
9
+ #
10
+ # A finder is responsible for detecting a single deprecation.
11
+ # Subclasses of Finder describe the deprecation with the class-level DSL and
12
+ # then implement one or more Prism visit hooks (e.g. `on_call_node`).
13
+ #
14
+ # A Scanner then follows the AST a single time and dispatches each node to the
15
+ # finders that have a relevant method, which call `add_offense` when a match is found.
16
+ #
17
+ # Example child class
18
+ #
19
+ # class MyFinder < Deprecool::Finder
20
+ # gem :ruby
21
+ # deprecated_in '4.0.0'
22
+ # removed_in '4.1.0'
23
+ # title 'some_method will be removed'
24
+ # summary 'some_method was causing a problem and will be removed soon'
25
+ # suggestion 'remove some_method'
26
+ # reference 'https://link_to_pr_or_relevant_info'
27
+ # effort :medium
28
+ #
29
+ # def on_call_node(node)
30
+ # add_offense(node, confidence: :high) if node.name == :some_method
31
+ # end
32
+ # end
33
+ class Finder
34
+ include PrismHelpers
35
+
36
+ @registry = []
37
+
38
+ class << self
39
+ # All finder subclasses, in definition order.
40
+ attr_reader :registry
41
+
42
+ def inherited(subclass)
43
+ Finder.registry << subclass
44
+ super
45
+ end
46
+
47
+ # defines class methods that set instance variables
48
+ %i[gem title summary suggestion reference].each do |attribute|
49
+ define_method(attribute) do |value = (getter = true)|
50
+ return instance_variable_get("@#{attribute}") if getter
51
+
52
+ instance_variable_set("@#{attribute}", value)
53
+ end
54
+ end
55
+
56
+ # Stored as a Gem::Version so it can be compared against the app's
57
+ # detected version.
58
+ %i[deprecated_in removed_in].each do |attribute|
59
+ define_method(attribute) do |value = (getter = true)|
60
+ return instance_variable_get("@#{attribute}") if getter
61
+
62
+ instance_variable_set("@#{attribute}", Gem::Version.new(value))
63
+ end
64
+ end
65
+
66
+ # how much work is this to fix?
67
+ # low => Rails::v7_1_0::SerializerPositionalClassArgument just changes a method signature to have kwarg
68
+ # medium => Ruby::v4_0_0::ObjectSpaceId2ref to keep same functionality you need to remove the method
69
+ # and minor refactor to use WeakMap or something
70
+ # high => you're gonna need to make some changes to preserve the same functionality
71
+ def effort(value = (getter = true))
72
+ return @effort if getter
73
+
74
+ values = %i[low medium high]
75
+
76
+ raise "Please use a standardized effort value, i.e #{values}" unless values.include?(value)
77
+
78
+ @effort = value
79
+ end
80
+
81
+ def affected_version_range
82
+ [deprecated_in, removed_in]
83
+ end
84
+
85
+ # return just the class name without all the modules, for displaying
86
+ def classname
87
+ name.split('::').last
88
+ end
89
+
90
+ # this is used internally to sort Finders
91
+ # so we might as well not sort the part thats repeated for every finder
92
+ def id
93
+ name.delete_prefix('Deprecool::Finders::')
94
+ end
95
+
96
+ # This is what the Scanner class calls to see what methods are
97
+ # defined on the child classes,
98
+ #
99
+ # child classes should define the methods with 'on' in place of 'visit'
100
+ # so that we can differentiate them from the default implementation
101
+ # provided by Prism::Visitor
102
+ #
103
+ # (see https://docs.ruby-lang.org/en/master/Prism/Visitor.html for the full list of
104
+ # Prism compatible methods)
105
+ # some examples of prism compatible 'on_node' methods for a finder:
106
+ # Prism::VisitClassNode => on_class_node
107
+ # Prism::VisitDefNode => on_def_node
108
+ # Prism::VisitModuleNode => on_module_node
109
+ # Prism::
110
+ def hook_methods
111
+ instance_methods(false).grep(/\Aon_\w+_node\z/)
112
+ end
113
+ end
114
+
115
+ attr_reader :file_path, :source
116
+
117
+ def initialize(file_path, source, offenses)
118
+ @file_path = file_path
119
+ @source = source # the result of Prism.parse
120
+ @offenses = offenses
121
+ end
122
+
123
+ Offense = Struct.new(:file_path, :line, :column, :end_line, :end_column,
124
+ :id, :title, :summary, :suggestion, :reference,
125
+ :effort, :confidence, :gem, :deprecated_in,
126
+ :removed_in, :snippet, :source_line) do
127
+ def location
128
+ "#{file_path}:#{line}:#{column + 1}"
129
+ end
130
+ end
131
+
132
+ private
133
+
134
+ # Record a deprecation at the given node's location.
135
+ #
136
+ # confidence - [:high, :low], but currently the only check for this is
137
+ # from the CLI outputting red or yellow depending on confidence
138
+ def add_offense(node, confidence: :high)
139
+ location = node.location # a Prism::Location
140
+
141
+ @offenses << Offense.new(
142
+ file_path: file_path,
143
+ line: location.start_line,
144
+ column: location.start_column,
145
+ end_line: location.end_line,
146
+ end_column: location.end_column,
147
+ id: self.class.id,
148
+ title: self.class.title,
149
+ summary: self.class.summary,
150
+ suggestion: self.class.suggestion,
151
+ reference: self.class.reference,
152
+ effort: self.class.effort,
153
+ confidence: confidence,
154
+ gem: self.class.gem,
155
+ deprecated_in: self.class.deprecated_in.to_s,
156
+ removed_in: self.class.removed_in.to_s,
157
+ snippet: location.slice,
158
+ source_line: source.lines[location.start_line - 1]&.chomp
159
+ )
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deprecool
4
+ module Finders
5
+ module Rails
6
+ module V7_1_0
7
+ class SerializerPositionalClassArgument < Deprecool::Finder
8
+ gem :rails
9
+ deprecated_in '7.1.0'
10
+ removed_in '7.2.0'
11
+ title '"serialize" method singature change'
12
+ summary 'The serialize method in active_record no longer accepts a class as a positional argument ' \
13
+ 'the method signature now requires custom serializer classes to be ' \
14
+ 'passed with the `coder:` keword argument.'
15
+ suggestion 'add the keyword `coder:` to the method signature, i.e serialize :attr, coder: CustomJsonEncoder'
16
+ reference 'https://github.com/rails/rails/pull/47463'
17
+ effort :low
18
+
19
+ # TODO: add check for the `serialize` method being called by a Rails model?,
20
+ # like add a on_constant_path_node for ActiveRecord::Base
21
+ # or a class_node with a superclass of ActiveRecord::Base
22
+ # def on_class_node node
23
+ # node
24
+ # end
25
+
26
+ def on_call_node(node)
27
+ return unless node.name == :serialize
28
+
29
+ arguments_array = unwrap_arguments(node.arguments)
30
+ return unless arguments_array && arguments_array[1]
31
+
32
+ add_offense(node, confidence: :high) if arguments_array[1].is_a?(Prism::ConstantReadNode)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deprecool
4
+ module Finders
5
+ module Rails
6
+ module V8_2_0
7
+ class RedisCacheStoreDefaultRedisOptions < Deprecool::Finder
8
+ gem :rails
9
+ deprecated_in '8.2.0'
10
+ removed_in '9.0.0'
11
+ title 'RedisCacheStore::DEFAULT_REDIS_OPTIONS is deprecated'
12
+ summary 'The `redis-client` implementation no longer reads this constant.'
13
+ suggestion 'Pass timeout options to RedisCacheStore or a configured RedisClient instead.'
14
+ reference 'https://github.com/rails/rails/pull/58191 '
15
+ effort :low
16
+
17
+ # constant_read is any constant, like `Foo`
18
+ def on_constant_read_node(node)
19
+ return unless node.name == :DEFAULT_REDIS_OPTIONS
20
+
21
+ add_offense(node, confidence: :high)
22
+ end
23
+
24
+ # a constant_path is like: ActiveSupport::Cache::RedisCacheStore::DEFAULT_REDIS_OPTIONS
25
+ def on_constant_path_node(node)
26
+ return unless node.name == :DEFAULT_REDIS_OPTIONS
27
+
28
+ add_offense(node, confidence: :high)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deprecool
4
+ module Finders
5
+ module Rails
6
+ module V8_2_0
7
+ class ToSqlBinds < Deprecool::Finder
8
+ gem :rails
9
+ deprecated_in '8.2.0'
10
+ removed_in '8.3.0'
11
+ title 'Passing "binds" into "to_sql" is deprecated'
12
+ summary 'Since Rails 5.2, bind parameters live on the Arel AST ' \
13
+ 'to_sql_and_binds no longer uses binds for SQL construction, ' \
14
+ 'and to_sql discards the output binds either way'
15
+ suggestion 'Do not use the binds argument of the to_sql method'
16
+ reference 'https://github.com/rails/rails/pull/58310'
17
+ effort :low
18
+
19
+ def on_call_node(node)
20
+ # to_sql is a method on an ActiveRecord::Base.connection
21
+ # the first argument should be a string of sql
22
+ # the second argument used to be an array of binds
23
+ # == the second argument is now deprecated ==
24
+ #
25
+ # the name of the connection is likely to be connection, but that's unreliable
26
+ # if a method called 'to_sql' is passed two arguments and the second is an
27
+ # array that's probably good enough
28
+ return unless node.name == :to_sql
29
+
30
+ arguments_array = unwrap_arguments(node.arguments)
31
+ return unless arguments_array && arguments_array[1]
32
+
33
+ add_offense(node, confidence: :high)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deprecool
4
+ module Finders
5
+ module Ruby
6
+ module V4_0_0
7
+ class ObjectSpaceId2ref < Deprecool::Finder
8
+ gem :ruby
9
+ deprecated_in '4.0.0'
10
+ removed_in '4.1.0'
11
+ title 'Using ObjectSpace._id2ref is deprecated'
12
+ summary 'The object_id identifier does not guarantee that the id won\'t be reused ' \
13
+ 'after the original has been garbage collected, ' \
14
+ 'therefore _id2ref is unsafe and unreliable, and per matz: ' \
15
+ '"Reviving arbitrary objects from integer IDs was never a sound API"'
16
+ suggestion 'Do not rely on this method'
17
+ reference 'original issue: https://bugs.ruby-lang.org/issues/15408' \
18
+ 'deprecated: https://github.com/ruby/ruby/pull/13157' \
19
+ 'removed: https://bugs.ruby-lang.org/issues/22135'
20
+ effort :medium
21
+
22
+ def on_call_node(node)
23
+ return unless node.name == :_id2ref
24
+
25
+ # The method name '_id2ref' is quite unique, and
26
+ # the use case for this is specific enough that
27
+ # I don't think we need much more than this
28
+ # I'm open to be wrong though, maybe lots of people are subclassing
29
+ # ObjectSpace or defining '_id2ref' on custom classes
30
+ confidence = node.receiver.name == :ObjectSpace ? :high : nil
31
+
32
+ return unless confidence
33
+
34
+ add_offense node, confidence: confidence
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deprecool
4
+ module Finders
5
+ module Ruby
6
+ module V4_0_0
7
+ class ToSetArguments < Deprecool::Finder
8
+ gem :ruby
9
+ deprecated_in '4.0.0'
10
+ removed_in '4.1.0'
11
+ title 'Passing arguments to #to_set is deprecated'
12
+ summary 'Since Ruby 4.0, passing arguments to Set#to_set / Enumerable#to_set ' \
13
+ 'is deprecated and will be removed.'
14
+ suggestion 'Call #to_set with no arguments. If you were building a ' \
15
+ 'Set subclass, construct it explicitly instead.'
16
+ reference 'https://bugs.ruby-lang.org/issues/21390 https://github.com/ruby/ruby/pull/13489'
17
+ effort :low
18
+
19
+ def on_call_node(node)
20
+ return unless node.name == :to_set
21
+ return unless node.arguments
22
+
23
+ confidence = confidence_from_receiver_node(node.receiver)
24
+ return unless confidence
25
+
26
+ add_offense(node, confidence:)
27
+ end
28
+
29
+ private
30
+
31
+ def confidence_from_receiver_node(receiver)
32
+ case receiver
33
+ # these nodes all represent Enumerables
34
+ when Prism::ArrayNode, Prism::HashNode, Prism::RangeNode then :high
35
+ # a plain `to_set(x)` call to the current scope's own
36
+ # method. This is probably not Enumerable#to_set unless someone has
37
+ # monkeypatched Enumerable and is using to_set with an argument, I guess
38
+ when nil then nil
39
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
40
+ # These nodes can constants like:
41
+ # MY_DATA.to_set(arg) => likely an array, but can't be sure so :low
42
+ # MyClass.to_set(arg) => has lowercase letters so it's a class method
43
+ receiver&.name&.match?(/[a-z]/) ? nil : :low
44
+ else
45
+ # this branch is when the receiver is some
46
+ # local var, instance variable, method chain, safe navigation, etc.
47
+ # could even be an a custom object — we can't really tell.
48
+ # but we do know enumerable methods like map, reduce, etc. so
49
+ # we can be more confident about that
50
+ return :high if Enumerable.method_defined?(receiver.name)
51
+
52
+ :low
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrismHelpers
4
+ # `(1..10)` parses as a ParenthesesNode wrapping the real expression.
5
+ def unwrap_parentheses(node)
6
+ return node unless node.is_a?(Prism::ParenthesesNode)
7
+
8
+ body = node.body&.body
9
+ body&.length == 1 ? body.first : node
10
+ end
11
+
12
+ # Method arguments are represented as:
13
+ # node.arguments returns a literal array of arguments
14
+ # https://docs.ruby-lang.org/en/master/Prism/ArgumentsNode.html
15
+ def unwrap_arguments(node)
16
+ return node unless node.is_a?(Prism::ArgumentsNode)
17
+
18
+ node.arguments
19
+ end
20
+
21
+ # https://docs.ruby-lang.org/en/master/Prism/ArrayNode.html
22
+ def unwrap_array(node)
23
+ return node unless node.is_a?(Prism::ArrayNode)
24
+
25
+ node.elements
26
+ end
27
+
28
+ def unwrap_class(node)
29
+ return node unless node.is_a?(Prism::ClassNode)
30
+
31
+ node.body
32
+ end
33
+
34
+ def unwrap_children(node)
35
+ return node unless node.respond_to?(:child_nodes)
36
+
37
+ node.child_nodes
38
+ end
39
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler'
4
+
5
+ module Deprecool
6
+ module LockfileParser
7
+ extend self
8
+
9
+ def parse!(path_to_gemfile_lock = 'Gemfile.lock', parser: Bundler::LockfileParser)
10
+ lockfile = File.read(path_to_gemfile_lock)
11
+
12
+ parsed = parser.new(lockfile)
13
+
14
+ versions = parsed.specs.map do |spec|
15
+ { gem: spec.name, version: spec.version.to_s }
16
+ end
17
+
18
+ gem, version = parsed.ruby_version.split
19
+ versions << { gem:, version: }
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deprecool
4
+ # Selects which finders should run for a given application, based on the gems
5
+ # (and Ruby) it actually uses and at what versions.
6
+ module Registry
7
+ extend self
8
+
9
+ # Every registered finder.
10
+ def all_finders
11
+ Finder.registry
12
+ end
13
+
14
+ # Gets finders that apply to the given versions.
15
+ # gems - an array of gem names
16
+ # gem_versions - an array of hashes like: { gem:, version: }
17
+ def applicable(gems: [], gem_versions: [], include_all: false)
18
+ return all_finders if include_all
19
+ return finders_by_gem(gems) if gems.any?
20
+ return finders_by_gem_version(gem_versions) if gem_versions.any?
21
+ end
22
+
23
+ def finders_by_gem(gems)
24
+ targets = []
25
+ gems.each do |gem|
26
+ targets << all_finders.select { it.to_s.match(/#{gem.capitalize}/) }
27
+ end
28
+ targets.flatten
29
+ end
30
+
31
+ def finders_by_gem_version(gem_versions)
32
+ finder_targets = []
33
+
34
+ gem_versions.each do |gem_version|
35
+ jem = gem_version[:gem].to_sym
36
+ version = Gem::Version.new(gem_version[:version])
37
+
38
+ # match when:
39
+ # 1) finder is for the current gem,
40
+ # 2) gem's version is higher than finder's deprecated,
41
+ # and 3) gems version is lower or equal to the removed_in
42
+ targets = all_finders.select do |finder|
43
+ (finder.gem == jem) &&
44
+ (finder.deprecated_in <= version) && (version <= finder.removed_in)
45
+ end
46
+
47
+ if !targets.empty?
48
+ finder_targets << targets
49
+ else
50
+ # this unfortunately floods the terminal for now, but one day maybe it won't
51
+ # puts "Oops, I don't have a Finder that applies to #{jem} v#{version}"
52
+ end
53
+ end
54
+
55
+ finder_targets.flatten
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deprecool
4
+ # Parses a source file with Prism and runs a set of finders against it in a
5
+ # single AST traversal.
6
+ class Scanner
7
+ def initialize(finders)
8
+ @finders = Array(finders)
9
+ end
10
+
11
+ def scan_file(path)
12
+ scan_source(File.read(path), path)
13
+ end
14
+
15
+ # Returns an Array<Offense>. Files that fail to parse yield no offenses;
16
+ # parse errors are surfaced separately via {#parse_errors}.
17
+ def scan_source(source, path = '(source)')
18
+ result = Prism.parse(source)
19
+ return [] unless result.success?
20
+
21
+ offenses = []
22
+ # each finder has the path, the whole source of the file, and the offenses list
23
+ # so that they can build the offense warning themselves.
24
+ # we might be able to refactor this to only pass the source because the path
25
+ # and offenses are only needed for the add_offense method, not the actual
26
+ # searching for an offense?
27
+ instances = @finders.map { |finder| finder.new(path, source, offenses) }
28
+ DispatchVisitor.new(instances).visit(result.value)
29
+ offenses
30
+ end
31
+
32
+ # Combines the given Finder classes into one class by
33
+ # defining one method per Prism::Visitor hook (e.g. visit_call_node)
34
+ # based on all the given finders that define a method that matches that
35
+ # hook method name
36
+ class DispatchVisitor < Prism::Visitor
37
+ # finder_instances is the array passed to the Scanner.new class,
38
+ # so this would be [Ruby::V4_0_0::ToSetArguments, ..]
39
+ def initialize(finder_instances)
40
+ super()
41
+
42
+ dispatch = Hash.new { |hash, key| hash[key] = [] }
43
+ finder_instances.each do |instance|
44
+ # find the hooks and add the instances to the hash of hook methods
45
+ # so { on_call_node: [ToSetArguments.new, ObjectSpaceId2ref.new,.. ], ... }
46
+ instance.class.hook_methods.each { |hook| dispatch[hook] << instance }
47
+ end
48
+
49
+ dispatch.each do |hook, finders|
50
+ # convert the instances' hooks to what prism::visitor expects
51
+ # so finder classes must use this pattern to name the visit_node methods
52
+ #
53
+ # 'on_call_node' => good
54
+ # 'find_call_node' => bad
55
+ visit_method = hook.to_s.sub(/\Aon_/, 'visit_').to_sym
56
+
57
+ # define the prism::visitor hook to loop through each of the
58
+ # related finders and call the name of the hook like so:
59
+ #
60
+ # def visit_call_node(node)
61
+ # [ToSetArguments.new, ObjectSpaceId2ref.new].each do |finder|
62
+ # finder.send("on_call_node", node)
63
+ # end
64
+ #
65
+ # super(node)
66
+ # end
67
+ define_singleton_method(visit_method) do |node|
68
+ finders.each { |finder| finder.send(hook, node) }
69
+ super(node)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deprecool
4
+ VERSION = '0.1.0'
5
+ end
6
+
data/lib/deprecool.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Deprecool
6
+ class Error < StandardError; end
7
+ end
8
+
9
+ require_relative 'deprecool/version'
10
+ require_relative 'deprecool/finder'
11
+ require_relative 'deprecool/registry'
12
+ require_relative 'deprecool/scanner'
13
+ require_relative 'deprecool/lockfile_parser'
14
+ require_relative 'deprecool/cli'
15
+
16
+ # TODO: Scan gemfiles and then only require relevant finders instead of all
17
+ # of them
18
+ Dir[File.join(__dir__, 'deprecool', 'finders', '**', '*.rb')].each do |finder|
19
+ require finder
20
+ end
21
+
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: deprecool
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Zac Radford
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: dry-cli
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: prism
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.0'
40
+ description: Static deprecation analysis tool to keep your code cool
41
+ email:
42
+ - zacnradford@gmail.com
43
+ executables:
44
+ - deprecool
45
+ extensions: []
46
+ extra_rdoc_files:
47
+ - CHANGELOG.md
48
+ - LICENSE
49
+ - README.md
50
+ files:
51
+ - CHANGELOG.md
52
+ - LICENSE
53
+ - README.md
54
+ - exe/deprecool
55
+ - lib/deprecool.rb
56
+ - lib/deprecool/cli.rb
57
+ - lib/deprecool/finder.rb
58
+ - lib/deprecool/finders/rails/v7.1.0/serializer_positional_class_argument.rb
59
+ - lib/deprecool/finders/rails/v8.2.0/redis_cache_store_default_redis_options.rb
60
+ - lib/deprecool/finders/rails/v8.2.0/to_sql_binds.rb
61
+ - lib/deprecool/finders/ruby/v4.0.0/object_space_id2ref.rb
62
+ - lib/deprecool/finders/ruby/v4.0.0/to_set_arguments.rb
63
+ - lib/deprecool/helpers/prism_helpers.rb
64
+ - lib/deprecool/lockfile_parser.rb
65
+ - lib/deprecool/registry.rb
66
+ - lib/deprecool/scanner.rb
67
+ - lib/deprecool/version.rb
68
+ homepage: https://github.com/zradford/deprecool
69
+ licenses:
70
+ - MIT
71
+ metadata:
72
+ changelog_uri: https://github.com/zradford/deprecool/CHANGELOG.md
73
+ source_code_uri: https://github.com/zradford/deprecool
74
+ bug_tracker_uri: https://github.com/zradford/deprecool/issues
75
+ rubygems_mfa_required: 'true'
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '3.3'
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 4.0.6
91
+ specification_version: 4
92
+ summary: Static deprecation analysis tool to keep your code cool
93
+ test_files: []