txray 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: f735a624f2fabb3ab9d3b2133c2b7793d6c836e730a6d71a3a5d21217f49e3d9
4
+ data.tar.gz: c76ae3d2e4aa426361fc0b2a834c76d5e33e910af92e541eaecacbe80ea462e9
5
+ SHA512:
6
+ metadata.gz: a10c45fade77e209f9c5bdc85bcb4453629ad9a2acf63d0067ff16a5a958512a6e4c31f9e1e54aedb60660e9c021d2513fe17b990aeb254ca607347138b73135
7
+ data.tar.gz: 27f2cdcd1bfaceb62be8ebc3ffedca7f90cf53104fc1f25a79af81ef48051d7e8e2d71649d354a184207ca4c7f347c4316fdd6a6795a1ea57442e5b68a35871d
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Static transaction analysis over explicit transactions, row and advisory locks, save callbacks, custom validations and migrations.
6
+ - Follows callbacks, concerns, service objects, `delegate` targets and `define_method` bodies across files.
7
+ - Tracks clients held in local variables, instance variables, constants and memoized readers.
8
+ - Text, json, sarif and github reporters, inline `# txray:disable` directives and configurable severities.
9
+ - Optional runtime guard with per-transaction attribution, an ignore API and a newline delimited JSON event log.
10
+ - `txray watch`, a live terminal monitor for transactions, durations and findings.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Theo Wecker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,274 @@
1
+ # txray
2
+
3
+ Static analysis that finds slow work hidden inside database transactions, plus a runtime guard and a live terminal monitor for what static analysis cannot see.
4
+
5
+ A transaction holds a connection and every row lock it has taken until it commits. Anything slow that happens in between (an HTTP request, a Stripe call, an SMTP delivery, a subprocess, a loop over a collection) extends that hold, and every writer queued behind those locks waits with it. The worst cases are the ones nobody wrote on purpose: an `after_create` callback three method calls away from a payment API.
6
+
7
+ txray parses your application with [Prism](https://github.com/ruby/prism) and follows callbacks, concerns, service objects and helper methods to find that work. It never boots your app and never runs your code, so it reports problems on paths your test suite has never executed.
8
+
9
+ ```
10
+ app/models/order.rb
11
+ 13:5 high external-service-in-transaction
12
+ External service call `Stripe::PaymentIntent.create(amount: total_cents)` runs inside the `after_create :settle` callback
13
+ via Order#charge_card (app/models/order.rb:12)
14
+ Third party clients hold the connection and the row locks for their full round trip. Call them after commit.
15
+ ```
16
+
17
+ ## Install
18
+
19
+ ```ruby
20
+ group :development, :test do
21
+ gem "txray", require: false
22
+ end
23
+ ```
24
+
25
+ ```sh
26
+ bundle exec txray
27
+ ```
28
+
29
+ It exits non zero when it finds something, so it drops straight into CI.
30
+
31
+ ## What counts as a transaction
32
+
33
+ txray does not only look for `transaction do`:
34
+
35
+ | Scope | Example |
36
+ | --- | --- |
37
+ | Explicit blocks | `Order.transaction { ... }`, `ActiveRecord::Base.transaction { ... }` |
38
+ | Row and advisory locks | `order.with_lock { ... }`, `with_advisory_lock { ... }`, any method that calls `lock!` |
39
+ | Callbacks that run inside the save transaction | `before_save`, `after_create`, `around_update`, `after_destroy`, `after_touch`, `before_commit` |
40
+ | Custom validations | `validate :vat_number_is_real`, `validate { ... }` |
41
+ | Migrations | `change`, `up` and `down` in an `ActiveRecord::Migration`, unless it calls `disable_ddl_transaction!` |
42
+
43
+ `after_commit`, `after_create_commit`, `after_rollback` and the rest of the commit callbacks run outside the transaction, so txray deliberately leaves them alone. That distinction is the whole point: moving a call from `after_create` to `after_commit` is usually the fix.
44
+
45
+ Two of these are easy to forget. Validations run inside the transaction `save` opens, so an API call in a custom validator holds the connection exactly like one in `before_save` (the `geocoder` gem's own README suggests `after_validation :geocode`, which is a network call inside your save transaction). And a migration body runs in a DDL transaction, so a data backfill that loops over a large table holds it for the length of the backfill.
46
+
47
+ ## What it looks for
48
+
49
+ | Rule | Severity |
50
+ | --- | --- |
51
+ | `http-in-transaction` | high |
52
+ | `external-service-in-transaction` | high |
53
+ | `mail-in-transaction` | high |
54
+ | `shell-in-transaction` | high |
55
+ | `sleep-in-transaction` | high |
56
+ | `job-enqueue-in-transaction` | medium |
57
+ | `broadcast-in-transaction` | medium |
58
+ | `upload-in-transaction` | medium |
59
+ | `iteration-in-transaction` | medium |
60
+ | `blocking-io-in-transaction` | medium |
61
+ | `cache-in-transaction` | low |
62
+ | `dynamic-dispatch-in-transaction` | low |
63
+
64
+ `bundle exec txray --rules` prints the list with the suggested fix for each.
65
+
66
+ ## Following the call
67
+
68
+ The interesting offenders are rarely in the transaction block itself:
69
+
70
+ ```ruby
71
+ class Order < ApplicationRecord
72
+ include Notifiable
73
+
74
+ after_create :settle
75
+
76
+ def settle
77
+ charge_card
78
+ notify_downstream
79
+ end
80
+
81
+ def charge_card
82
+ Stripe::PaymentIntent.create(amount: total_cents)
83
+ end
84
+ end
85
+ ```
86
+
87
+ txray resolves `after_create :settle` to `Order#settle`, follows `charge_card` into the same class and `notify_downstream` into the `Notifiable` concern in another file, and reports both calls with the path it took to reach them.
88
+
89
+ It follows bare calls in the same class, `self.` calls into class methods, `Constant.method` calls into another class, and `Constant.new(...).method` into a service object. A transaction that only says `Checkout.new(order).call` is still traced to the Stripe call three files away. `--depth` controls how far it follows (three levels by default).
90
+
91
+ ## Clients held in variables
92
+
93
+ Most real client calls never name a risky constant:
94
+
95
+ ```ruby
96
+ class Gateway
97
+ def charge(order)
98
+ ApplicationRecord.transaction { client.post("/charges", order.to_json) }
99
+ end
100
+
101
+ def client = @client ||= Faraday.new(url: ENV["API"])
102
+ end
103
+ ```
104
+
105
+ Building a client marks the binding rather than reporting it, and a call on a marked binding is the offense. txray tracks clients through local variables, instance variables, constants, memoized reader methods and `delegate` targets, and reports the request (`client.post`) rather than the harmless construction. Chained calls like `Twilio::REST::Client.new(sid, token).messages.create(...)` are reported once, at the outermost call.
106
+
107
+ Add your own wrappers with `external_clients` in the config and they are treated the same way.
108
+
109
+ ## Metaprogramming
110
+
111
+ txray resolves what it can and is explicit about what it cannot:
112
+
113
+ - `send`/`public_send` with a symbol literal is followed like a direct call
114
+ - methods built with `define_method` are indexed and followed
115
+ - callbacks registered from a concern's `included do` block resolve, including when the method lives on the host class
116
+ - `delegate :charge, to: :gateway` follows through to the target
117
+
118
+ When the target genuinely cannot be resolved (`send("#{provider}_charge")`), txray reports `dynamic-dispatch-in-transaction` at low severity rather than passing silently, so the blind spot is visible instead of invisible.
119
+
120
+ What static analysis cannot see: a client passed in as an argument, dispatch through `method_missing`, and work buried inside a gem. That is what the runtime guard is for.
121
+
122
+ ## Configuration
123
+
124
+ ```sh
125
+ bundle exec txray --init
126
+ ```
127
+
128
+ `.txray.yml` is discovered by walking up from the working directory, so it keeps working from subdirectories and from an editor integration.
129
+
130
+ ```yaml
131
+ include:
132
+ - app
133
+ - lib
134
+ - db/migrate
135
+ exclude:
136
+ - spec
137
+ - test
138
+ - vendor
139
+ max_depth: 3
140
+ fail_level: low
141
+ disabled_rules:
142
+ - cache-in-transaction
143
+ severities:
144
+ broadcast-in-transaction: high
145
+ external_clients:
146
+ - InternalApi
147
+ - LegacySoapClient
148
+ runtime:
149
+ enabled: false
150
+ threshold_ms: 250
151
+ on_violation: log
152
+ log_path: tmp/txray.ndjson
153
+ ignore:
154
+ - LegacyImporter
155
+ ```
156
+
157
+ Exclusions apply to the path below the root being scanned, so `vendor` means the vendor directory in your project, not any directory named vendor anywhere above it.
158
+
159
+ ### Inline suppression
160
+
161
+ ```ruby
162
+ def charge = Faraday.post(url) # txray:disable
163
+
164
+ # txray:disable http-in-transaction
165
+ Faraday.post(url)
166
+ ```
167
+
168
+ A bare `# txray:disable` disables every rule on that line; naming rules disables only those. The comment works on the offending line or the line directly above it.
169
+
170
+ ## Command line
171
+
172
+ ```
173
+ Usage: txray [options] [paths]
174
+ txray watch [options]
175
+
176
+ -f, --format FORMAT text, json, sarif or github (default: text)
177
+ -c, --config PATH path to a .txray.yml file
178
+ --fail-level low, medium, high or none (default: low)
179
+ --only RULES report only these rule ids
180
+ --except RULES skip these rule ids
181
+ --depth N how far to follow method calls (default: 3)
182
+ --rules list every rule and exit
183
+ --init write a default .txray.yml
184
+ --file PATH watch: event log written by the runtime guard
185
+ --threshold MS watch: slow transaction threshold
186
+ --from-start watch: replay the existing log first
187
+ ```
188
+
189
+ ## CI
190
+
191
+ ```yaml
192
+ - name: Scan for slow transactions
193
+ run: bundle exec txray --format github
194
+ ```
195
+
196
+ `--format github` writes inline annotations on the pull request. `--format sarif` uploads to GitHub code scanning:
197
+
198
+ ```yaml
199
+ - run: bundle exec txray --format sarif > txray.sarif || true
200
+ - uses: github/codeql-action/upload-sarif@v3
201
+ with:
202
+ sarif_file: txray.sarif
203
+ ```
204
+
205
+ ## Runtime guard
206
+
207
+ Static analysis cannot see through metaprogramming, gem internals or a client handed in as an argument. The optional runtime guard catches what is left, from inside a running application:
208
+
209
+ ```yaml
210
+ runtime:
211
+ enabled: true
212
+ threshold_ms: 250
213
+ on_violation: raise
214
+ ```
215
+
216
+ It reports a transaction that stays open past the threshold, a job enqueued while a transaction is open, mail delivered while a transaction is open, and any `Net::HTTP` request made while a transaction is open (which covers Faraday, HTTParty, RestClient, Octokit and everything else built on it). Each finding is attributed to the transaction that was open, with the duration of the offending call and the application frame that caused it. `on_violation: log` warns, `raise` fails loudly. Run it as `raise` in test and `log` in development.
217
+
218
+ Known-good work can be excused:
219
+
220
+ ```ruby
221
+ Txray::Runtime.ignore do
222
+ LegacyImporter.run!
223
+ end
224
+ ```
225
+
226
+ `ignore` in the config takes patterns matched against the message and source of each finding. `Txray::Runtime.uninstall` tears the guard back down, which test suites need when they install it per example.
227
+
228
+ ## Live monitor
229
+
230
+ The guard writes newline delimited JSON to `runtime.log_path`. `txray watch` tails it and renders what your application is doing right now:
231
+
232
+ ```
233
+ txray watching tmp/txray.ndjson 00:04:12
234
+
235
+ transactions 142 seen 9 slow 4 with findings
236
+ duration p50 12ms p95 240ms max 1.84s
237
+ ▁▁▂▁▁▁▁▁▁▂▁▁▁▁▁▅▁▁▁▁▁▂▁▁▁▁▁▁▁▁▂█
238
+
239
+ LIVE
240
+ 14:02:11 1.84s x app/models/order.rb:31 settle
241
+ | http-in-transaction POST api.stripe.com/v1/charges (1.61s)
242
+ | mail-in-transaction ReceiptMailer delivered mail (180ms)
243
+ 14:02:10 268ms ! app/services/checkout.rb:12 call
244
+ 14:02:09 11ms . app/models/photo.rb:8 save
245
+
246
+ HOTSPOTS
247
+ 3x http-in-transaction app/models/order.rb:34
248
+ 1x job-enqueue-in-transaction app/models/photo.rb:8
249
+
250
+ ctrl-c to stop
251
+ ```
252
+
253
+ Findings are grouped under the transaction that produced them, so you see the transaction that took 1.84 seconds and the two calls that account for most of it. Ctrl-C prints a summary of the session. Piped to a file or another process it emits the raw event stream instead of the dashboard, so `txray watch | jq` works.
254
+
255
+ The log is append only with an exclusive lock per write, so several Puma workers and Sidekiq processes can share one file.
256
+
257
+ ## How this differs from what already exists
258
+
259
+ [isolator](https://github.com/palkan/isolator) detects the same class of problem at runtime, so it only sees code your tests actually execute. txray answers the same question statically: no boot, no database, no coverage requirement, and it works on a cold checkout of a codebase you have never run. Its runtime guard then covers the rest, and adds transaction duration and a live view, which isolator does not do. The two compose well. Nothing in `rubocop-rails` covers this.
260
+
261
+ ## Roadmap
262
+
263
+ The rule registry is keyed by category so the transaction rules are the first family rather than the only one. Planned: query rules (N+1 shapes in views and loops, `count` where `size` would do, unbounded `all` loads, missing `includes`), schema rules (unindexed foreign keys and filtered columns), and job rules (Active Record objects passed as job arguments).
264
+
265
+ ## Development
266
+
267
+ ```sh
268
+ bin/setup
269
+ bundle exec rake
270
+ ```
271
+
272
+ ## License
273
+
274
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core/rake_task"
4
+ require "rubocop/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+ RuboCop::RakeTask.new
8
+
9
+ task default: %i[spec rubocop]
data/exe/txray ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "txray"
5
+
6
+ exit Txray::CLI.start(ARGV)
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txray
4
+ class Analyzer
5
+ def initialize(index:, clients:, config:)
6
+ @index = index
7
+ @clients = clients
8
+ @config = config
9
+ @classifier = Classifier.new(config)
10
+ @scope_finder = ScopeFinder.new(index)
11
+ end
12
+
13
+ def call(source)
14
+ offenses = {}
15
+ @scope_finder.call(source).each do |scope|
16
+ walk = Walk.new(scope: scope, suppressed: Set.new, visited: Set.new)
17
+ explore(scope.body, scope.namespace, scope.source, walk, 0, []) do |offense|
18
+ current = offenses[offense.key]
19
+ offenses[offense.key] = offense if current.nil? || offense.trace.size < current.trace.size
20
+ end
21
+ end
22
+ offenses.values
23
+ end
24
+
25
+ private
26
+
27
+ Walk = Struct.new(:scope, :suppressed, :visited, keyword_init: true)
28
+ Context = Struct.new(:namespace, :path, keyword_init: true)
29
+
30
+ def explore(node, namespace, source, walk, depth, trace, &emit)
31
+ NodeHelpers.each_node(node) do |current|
32
+ report(current, namespace, source, walk, trace, &emit)
33
+ follow(current, namespace, walk, depth, trace, &emit) if current.is_a?(Prism::CallNode)
34
+ end
35
+ end
36
+
37
+ def report(node, namespace, source, walk, trace, &emit)
38
+ return if walk.suppressed.include?(node.object_id)
39
+
40
+ rule_id = rule_for(node, namespace, source)
41
+ return unless rule_id && @config.rule_enabled?(rule_id)
42
+ return if source.disabled?(node.location.start_line, rule_id)
43
+
44
+ suppress_receivers(node, walk)
45
+ emit.call(Offense.new(rule: @config.rule(rule_id), path: source.path, line: node.location.start_line,
46
+ column: node.location.start_column + 1, snippet: NodeHelpers.snippet(node),
47
+ scope: walk.scope, trace: trace))
48
+ end
49
+
50
+ def rule_for(node, namespace, source)
51
+ @classifier.call(node) || client_rule(node, namespace, source) ||
52
+ (@classifier.iteration?(node) ? "iteration-in-transaction" : nil)
53
+ end
54
+
55
+ def client_rule(node, namespace, source)
56
+ return nil unless node.is_a?(Prism::CallNode)
57
+ return @clients.delegated_kind(namespace, node.name) if node.receiver.nil?
58
+
59
+ @clients.kind_of(node.receiver, Context.new(namespace: namespace, path: source.path))
60
+ end
61
+
62
+ def suppress_receivers(node, walk)
63
+ receiver = node.receiver if node.is_a?(Prism::CallNode)
64
+ while receiver.is_a?(Prism::CallNode)
65
+ walk.suppressed << receiver.object_id
66
+ receiver = receiver.receiver
67
+ end
68
+ end
69
+
70
+ def follow(call, namespace, walk, depth, trace, &)
71
+ return unless depth < @config.max_depth
72
+
73
+ entry = resolve(call, namespace)
74
+ return if entry.nil? || entry.node.body.nil?
75
+ return unless walk.visited.add?([ entry.namespace, entry.name ])
76
+
77
+ frame = Frame.new(label: entry.label, path: entry.path, line: entry.line)
78
+ explore(entry.node.body, entry.namespace, entry.source, walk, depth + 1, trace + [ frame ], &)
79
+ end
80
+
81
+ def resolve(call, namespace)
82
+ name = dispatched_name(call)
83
+ return nil if name.nil?
84
+
85
+ case call.receiver
86
+ when nil then @index.lookup(namespace, name)
87
+ when Prism::SelfNode then @index.lookup(namespace, name, singleton: true) || @index.lookup(namespace, name)
88
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
89
+ @index.lookup(NodeHelpers.constant_name(call.receiver), name, singleton: true)
90
+ when Prism::CallNode then resolve_instance(call, name)
91
+ end
92
+ end
93
+
94
+ def dispatched_name(call)
95
+ return call.name unless Catalog::DISPATCH_METHODS.include?(call.name)
96
+
97
+ argument = NodeHelpers.positional_arguments(call).first
98
+ argument.unescaped.to_sym if argument.is_a?(Prism::SymbolNode)
99
+ end
100
+
101
+ def resolve_instance(call, name)
102
+ receiver = call.receiver
103
+ return unless receiver.name == :new
104
+
105
+ owner = NodeHelpers.constant_name(receiver.receiver)
106
+ owner && @index.lookup(owner, name)
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txray
4
+ module Catalog
5
+ HTTP_NAMESPACES = %w[
6
+ Net::HTTP Net::HTTPS Net::SMTP Net::IMAP Net::POP3 Net::FTP Net::SSH Net::SFTP Net::Telnet
7
+ Faraday HTTParty RestClient Excon Typhoeus HTTPX HTTPClient HTTP Curl Patron Mechanize Down
8
+ Savon GraphQL::Client OpenURI Socket TCPSocket UDPSocket
9
+ ].freeze
10
+
11
+ SERVICE_NAMESPACES = %w[
12
+ Stripe Braintree PayPal Recurly Chargebee Coinbase Plaid Adyen Square
13
+ Aws AWS Azure Google GoogleDrive Firebase Cloudinary Imgix
14
+ Twilio SendGrid Mailgun Postmark Mailchimp Customerio Intercom Zendesk Front
15
+ Slack Discordrb Octokit Gitlab Shopify Salesforce Hubspot Airtable Notion
16
+ Algolia Elasticsearch OpenSearch Meilisearch Typesense Searchkick Sunspot
17
+ Geocoder Mapbox LaunchDarkly Fog Vonage Nexmo Resend Pay Auth0 Okta Clerk
18
+ Pusher Ably Segment Mixpanel Amplitude Posthog
19
+ OpenAI Anthropic Gemini Replicate Pinecone RubyLLM Ollama Langchain
20
+ Onfido Persona Checkr Twitter Linkedin Zoom
21
+ ].freeze
22
+
23
+ CACHE_NAMESPACES = %w[Redis Dalli Memcached MemCache].freeze
24
+
25
+ SHELL_METHODS = %i[system spawn exec fork popen popen2 popen3 capture2 capture2e capture3 pipeline].freeze
26
+ SHELL_NAMESPACES = %w[Open3 Process Kernel IO PTY].freeze
27
+
28
+ MAIL_METHODS = %i[deliver_now deliver_now!].freeze
29
+
30
+ SEARCH_METHODS = %i[reindex reindex! geocode reverse_geocode].freeze
31
+
32
+ BROADCAST_METHODS = %i[
33
+ broadcast_to broadcast_replace_to broadcast_update_to broadcast_append_to broadcast_prepend_to
34
+ broadcast_remove_to broadcast_before_to broadcast_after_to broadcast_action_to broadcast_render_to
35
+ broadcast_refresh_to broadcast_replace broadcast_update broadcast_append broadcast_prepend
36
+ ].freeze
37
+
38
+ DISPATCH_METHODS = %i[send public_send __send__ try try!].freeze
39
+
40
+ CONSTRUCTOR_METHODS = %i[new client connection build configure resource service session].freeze
41
+
42
+ ENQUEUE_METHODS = %i[
43
+ perform_later deliver_later deliver_later! perform_async perform_in perform_at
44
+ enqueue enqueue_at broadcast_later broadcast_later_to
45
+ ].freeze
46
+
47
+ ATTACHMENT_METHODS = %i[attach purge purge_later analyze processed].freeze
48
+
49
+ ITERATOR_METHODS = %i[
50
+ each each_with_object each_with_index each_slice each_entry map flat_map
51
+ find_each find_in_batches in_batches collect select filter reject sum times upto downto
52
+ ].freeze
53
+
54
+ PERSISTENCE_METHODS = %i[
55
+ save save! update update! update_attribute update_column update_columns
56
+ create create! destroy destroy! delete touch increment! decrement! toggle!
57
+ insert insert! insert_all insert_all! upsert upsert_all
58
+ find find_by find_by! where pluck exists? reload lock! first last count
59
+ ].freeze
60
+
61
+ MEDIA_NAMESPACES = %w[
62
+ CSV Tempfile Zip Rubyzip Prawn WickedPdf Grover Ferrum Roo Axlsx Caxlsx Spreadsheet
63
+ MiniMagick ImageProcessing Vips Magick RMagick FFMPEG Streamio Shrine
64
+ ].freeze
65
+
66
+ FILE_NAMESPACES = %w[File IO Pathname FileUtils].freeze
67
+ FILE_METHODS = %i[read write open binread binwrite readlines foreach copy_stream cp mv rm_rf].freeze
68
+
69
+ CALLBACKS = %i[
70
+ before_commit
71
+ before_validation after_validation
72
+ before_save around_save after_save
73
+ before_create around_create after_create
74
+ before_update around_update after_update
75
+ before_destroy around_destroy after_destroy
76
+ after_touch
77
+ ].freeze
78
+
79
+ MIGRATION_METHODS = %i[change up down].freeze
80
+
81
+ module_function
82
+
83
+ def namespaced?(constant, namespaces)
84
+ return false if constant.nil?
85
+
86
+ namespaces.any? { |namespace| constant == namespace || constant.start_with?("#{namespace}::") }
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txray
4
+ class Classifier
5
+ def initialize(config)
6
+ @config = config
7
+ @clients = Catalog::SERVICE_NAMESPACES + config.external_clients
8
+ end
9
+
10
+ def call(node)
11
+ return "shell-in-transaction" if node.is_a?(Prism::XStringNode)
12
+ return nil unless node.is_a?(Prism::CallNode)
13
+
14
+ name = node.name
15
+ constant = NodeHelpers.constant_name(node.receiver)
16
+
17
+ return "shell-in-transaction" if shell?(node, name, constant)
18
+ return "sleep-in-transaction" if blocking?(node, name, constant)
19
+ return "mail-in-transaction" if Catalog::MAIL_METHODS.include?(name)
20
+ return "job-enqueue-in-transaction" if enqueue?(name, constant)
21
+ return "broadcast-in-transaction" if broadcast?(node, name)
22
+ return "upload-in-transaction" if Catalog::ATTACHMENT_METHODS.include?(name)
23
+ return "external-service-in-transaction" if Catalog::SEARCH_METHODS.include?(name)
24
+ return "dynamic-dispatch-in-transaction" if unresolvable_dispatch?(node, name)
25
+ return "cache-in-transaction" if rails_cache?(node)
26
+ return nil if constructor?(node, name)
27
+
28
+ constant_rule(constant) || implicit_http(name, constant)
29
+ end
30
+
31
+ def constant_rule(constant)
32
+ return nil if constant.nil?
33
+ return "http-in-transaction" if Catalog.namespaced?(constant, Catalog::HTTP_NAMESPACES)
34
+ return "cache-in-transaction" if Catalog.namespaced?(constant, Catalog::CACHE_NAMESPACES)
35
+ return "blocking-io-in-transaction" if Catalog.namespaced?(constant, Catalog::MEDIA_NAMESPACES)
36
+ return "external-service-in-transaction" if Catalog.namespaced?(constant, @clients)
37
+
38
+ nil
39
+ end
40
+
41
+ def iteration?(node)
42
+ return false unless node.is_a?(Prism::CallNode) && node.block && Catalog::ITERATOR_METHODS.include?(node.name)
43
+
44
+ NodeHelpers.each_node(NodeHelpers.block_body(node)) do |child|
45
+ return true if child.is_a?(Prism::CallNode) && Catalog::PERSISTENCE_METHODS.include?(child.name)
46
+ end
47
+ false
48
+ end
49
+
50
+ def constructor?(node, name = node.name)
51
+ Catalog::CONSTRUCTOR_METHODS.include?(name) && !constant_rule(NodeHelpers.constant_name(node.receiver)).nil?
52
+ end
53
+
54
+ private
55
+
56
+ def implicit_http(name, constant)
57
+ return "http-in-transaction" if name == :open && %w[URI OpenURI Kernel].include?(constant)
58
+ return "blocking-io-in-transaction" if file_io?(name, constant)
59
+
60
+ nil
61
+ end
62
+
63
+ def file_io?(name, constant)
64
+ Catalog::FILE_NAMESPACES.include?(constant) && Catalog::FILE_METHODS.include?(name)
65
+ end
66
+
67
+ def shell?(node, name, constant)
68
+ return false unless Catalog::SHELL_METHODS.include?(name)
69
+
70
+ node.receiver.nil? || Catalog.namespaced?(constant, Catalog::SHELL_NAMESPACES)
71
+ end
72
+
73
+ def blocking?(node, name, constant)
74
+ return true if name == :sleep && (node.receiver.nil? || constant == "Kernel")
75
+
76
+ name == :timeout && constant == "Timeout"
77
+ end
78
+
79
+ def enqueue?(name, constant)
80
+ return true if Catalog::ENQUEUE_METHODS.include?(name)
81
+
82
+ name == :push && constant == "Sidekiq::Client"
83
+ end
84
+
85
+ def broadcast?(node, name)
86
+ return true if Catalog::BROADCAST_METHODS.include?(name)
87
+
88
+ name == :broadcast && NodeHelpers.receiver_name(node).to_s.start_with?("ActionCable")
89
+ end
90
+
91
+ def unresolvable_dispatch?(node, name)
92
+ return false unless Catalog::DISPATCH_METHODS.include?(name)
93
+
94
+ argument = NodeHelpers.positional_arguments(node).first
95
+ !argument.nil? && !argument.is_a?(Prism::SymbolNode)
96
+ end
97
+
98
+ def rails_cache?(node)
99
+ NodeHelpers.receiver_name(node).to_s.start_with?("Rails.cache", "$redis", "@redis")
100
+ end
101
+ end
102
+ end