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.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txray
4
+ Result = Struct.new(:offenses, :files, :skipped, keyword_init: true) do
5
+ def counts = offenses.group_by(&:severity).transform_values(&:size)
6
+ def worst = offenses.map(&:severity_rank).max
7
+ def failing?(level) = worst && worst >= (Offense::SEVERITIES.index(level) || Offense::SEVERITIES.size)
8
+ end
9
+
10
+ class Scanner
11
+ def initialize(config, paths: nil)
12
+ @config = config
13
+ @paths = Array(paths).reject(&:empty?)
14
+ end
15
+
16
+ def run
17
+ sources = []
18
+ skipped = []
19
+ files.each do |path|
20
+ source = SourceFile.parse(path)
21
+ source ? sources << source : skipped << path
22
+ end
23
+
24
+ Result.new(offenses: analyze(sources), files: sources.map(&:path), skipped: skipped)
25
+ end
26
+
27
+ def analyze(sources)
28
+ index = MethodIndex.new
29
+ clients = ClientIndex.new(Classifier.new(@config))
30
+ sources.each do |source|
31
+ index.index(source)
32
+ clients.index(source)
33
+ end
34
+
35
+ analyzer = Analyzer.new(index: index, clients: clients, config: @config)
36
+ dedupe(sources.flat_map { |source| analyzer.call(source) }).sort_by(&:sort_key)
37
+ end
38
+
39
+ def dedupe(offenses)
40
+ offenses.group_by(&:key).values.map { |group| group.min_by { |offense| offense.trace.size } }
41
+ end
42
+
43
+ def files
44
+ roots = @paths.empty? ? @config.includes : @paths
45
+ verify(roots) unless @paths.empty?
46
+ roots.flat_map { |root| expand(root).reject { |path| excluded?(path, root) } }.uniq.sort
47
+ end
48
+
49
+ private
50
+
51
+ def verify(roots)
52
+ missing = roots.reject { |root| File.exist?(root) }
53
+ raise Error, "no such file or directory: #{missing.join(", ")}" if missing.any?
54
+ end
55
+
56
+ def expand(root)
57
+ return [ root ] if File.file?(root)
58
+
59
+ Dir.glob(File.join(root, "**", "*.rb"))
60
+ end
61
+
62
+ def excluded?(path, root)
63
+ relative = path.delete_prefix(root.chomp(File::SEPARATOR)).delete_prefix(File::SEPARATOR)
64
+ segments = relative.split(File::SEPARATOR)
65
+ @config.excludes.any? do |pattern|
66
+ segments.include?(pattern) || File.fnmatch?(pattern, relative, File::FNM_PATHNAME)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txray
4
+ class SourceFile
5
+ DIRECTIVE = /#\s*txray:disable(?<rules>[\w,\s-]*)/
6
+
7
+ attr_reader :path, :root
8
+
9
+ def self.parse(path, code: nil)
10
+ code ||= File.read(path)
11
+ result = Prism.parse(code)
12
+ return nil if result.failure?
13
+
14
+ new(path: path, root: result.value, comments: result.comments)
15
+ rescue StandardError
16
+ nil
17
+ end
18
+
19
+ def initialize(path:, root:, comments: [])
20
+ @path = path
21
+ @root = root
22
+ @directives = build_directives(comments)
23
+ end
24
+
25
+ def disabled?(line, rule_id)
26
+ rules = @directives[line] || @directives[line - 1]
27
+ return false if rules.nil?
28
+
29
+ rules.empty? || rules.include?(rule_id)
30
+ end
31
+
32
+ private
33
+
34
+ def build_directives(comments)
35
+ comments.each_with_object({}) do |comment, directives|
36
+ match = DIRECTIVE.match(comment.slice)
37
+ next unless match
38
+
39
+ directives[comment.location.start_line] = match[:rules].to_s.split(/[,\s]+/).reject(&:empty?)
40
+ end
41
+ end
42
+ end
43
+ end
data/lib/txray/tail.rb ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txray
4
+ class Tail
5
+ INTERVAL = 0.15
6
+
7
+ def initialize(path, from_start: false)
8
+ @path = path
9
+ @skip_existing = !from_start && File.exist?(path)
10
+ end
11
+
12
+ def each_batch
13
+ loop do
14
+ yield read
15
+ sleep INTERVAL
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def read
22
+ return [] unless File.exist?(@path)
23
+
24
+ reopen if @file.nil? || rotated?
25
+ @file.read.to_s.lines.map(&:chomp).reject(&:empty?)
26
+ rescue StandardError
27
+ []
28
+ end
29
+
30
+ def rotated?
31
+ return true unless File.identical?(@path, @file)
32
+
33
+ File.size(@path) < @file.pos
34
+ end
35
+
36
+ def reopen
37
+ @file&.close
38
+ @file = File.open(@path, "r")
39
+ @file.seek(0, IO::SEEK_END) if @skip_existing
40
+ @skip_existing = false
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :txray do
4
+ desc "Scan the application for transactions that hold the database open"
5
+ task :scan do
6
+ require "txray"
7
+ exit Txray::CLI.start(ENV.fetch("TXRAY_ARGS", "").split)
8
+ end
9
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txray
4
+ TransactionScope = Struct.new(:kind, :label, :body, :namespace, :source, :line, keyword_init: true) do
5
+ def path = source.path
6
+ end
7
+
8
+ class ScopeFinder
9
+ LOCK_BLOCKS = %i[with_lock with_advisory_lock].freeze
10
+
11
+ def initialize(index)
12
+ @index = index
13
+ end
14
+
15
+ def call(source)
16
+ migrations = Set.new
17
+ modules = Set.new
18
+ scopes = []
19
+
20
+ Namespaces.each(source.root) do |namespace, node|
21
+ case node
22
+ when Prism::ClassNode then migrations << namespace if transactional_migration?(node)
23
+ when Prism::ModuleNode then modules << namespace
24
+ when Prism::CallNode then scopes.concat(from_call(namespace, node, source, modules))
25
+ when Prism::DefNode then scopes.concat(from_definition(namespace, node, source, migrations))
26
+ end
27
+ end
28
+
29
+ scopes.compact
30
+ end
31
+
32
+ private
33
+
34
+ def from_call(namespace, call, source, modules)
35
+ case call.name
36
+ when *Catalog::CALLBACKS then registration_scopes(namespace, call, source, "callback", modules)
37
+ when :validate then registration_scopes(namespace, call, source, "validation", modules)
38
+ when :transaction then [ block_scope(namespace, call, source, :transaction, transaction_label(call)) ]
39
+ when *LOCK_BLOCKS then [ block_scope(namespace, call, source, :lock, "a `#{call.name}` block") ]
40
+ else []
41
+ end
42
+ end
43
+
44
+ def registration_scopes(namespace, call, source, noun, modules)
45
+ return [] unless call.receiver.nil?
46
+
47
+ scopes = NodeHelpers.symbol_arguments(call).filter_map do |method_name|
48
+ entry = @index.lookup(namespace, method_name)
49
+ entry ||= @index.unique(method_name) if modules.include?(namespace)
50
+ next unless entry&.node&.body
51
+
52
+ TransactionScope.new(
53
+ kind: :callback,
54
+ label: "the `#{call.name} :#{method_name}` #{noun}",
55
+ body: entry.node.body,
56
+ namespace: entry.namespace,
57
+ source: entry.source,
58
+ line: call.location.start_line
59
+ )
60
+ end
61
+
62
+ scopes << block_scope(namespace, call, source, :callback, "the `#{call.name}` #{noun} block")
63
+ scopes
64
+ end
65
+
66
+ def block_scope(namespace, call, source, kind, label)
67
+ body = NodeHelpers.block_body(call)
68
+ return unless body
69
+
70
+ TransactionScope.new(
71
+ kind: kind,
72
+ label: label,
73
+ body: body,
74
+ namespace: namespace,
75
+ source: source,
76
+ line: call.location.start_line
77
+ )
78
+ end
79
+
80
+ def transaction_label(call)
81
+ receiver = NodeHelpers.receiver_name(call)
82
+ receiver ? "the `#{receiver}.transaction` block" : "an explicit `transaction` block"
83
+ end
84
+
85
+ def from_definition(namespace, node, source, migrations)
86
+ return [] if node.body.nil?
87
+
88
+ if migrations.include?(namespace) && Catalog::MIGRATION_METHODS.include?(node.name)
89
+ return [ scope_for(:migration, "`#{namespace}##{node.name}`, which runs in a DDL transaction", node,
90
+ namespace, source) ]
91
+ end
92
+
93
+ return [] unless locks_row?(node.body)
94
+
95
+ [ scope_for(:lock, "`#{namespace}##{node.name}`, which locks a row", node, namespace, source) ]
96
+ end
97
+
98
+ def scope_for(kind, label, node, namespace, source)
99
+ TransactionScope.new(
100
+ kind: kind,
101
+ label: label,
102
+ body: node.body,
103
+ namespace: namespace,
104
+ source: source,
105
+ line: node.location.start_line
106
+ )
107
+ end
108
+
109
+ def transactional_migration?(node)
110
+ return false unless node.superclass&.slice.to_s.include?("ActiveRecord::Migration")
111
+
112
+ NodeHelpers.each_node(node.body) do |child|
113
+ return false if child.is_a?(Prism::CallNode) && child.name == :disable_ddl_transaction!
114
+ end
115
+ true
116
+ end
117
+
118
+ def locks_row?(body)
119
+ NodeHelpers.each_node(body) do |node|
120
+ return true if node.is_a?(Prism::CallNode) && node.name == :lock! && node.block.nil?
121
+ end
122
+ false
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txray
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txray
4
+ class WatchCommand
5
+ def initialize(path:, threshold_ms:, io: $stdout, from_start: false)
6
+ @path = path
7
+ @io = io
8
+ @monitor = Monitor.new(threshold_ms: threshold_ms)
9
+ @tail = Tail.new(path, from_start: from_start)
10
+ @view = Reporters::Live.new(io: io, path: path, threshold_ms: threshold_ms)
11
+ end
12
+
13
+ def run
14
+ return stream unless @io.tty?
15
+
16
+ @view.open
17
+ @view.draw(@monitor)
18
+ @tail.each_batch do |lines|
19
+ next if lines.empty?
20
+
21
+ lines.each { |line| @monitor.absorb(line) }
22
+ @view.draw(@monitor)
23
+ end
24
+ rescue Interrupt
25
+ nil
26
+ ensure
27
+ @view.close(@monitor) if @io.tty?
28
+ end
29
+
30
+ private
31
+
32
+ def stream
33
+ @tail.each_batch do |lines|
34
+ lines.each do |line|
35
+ event = @monitor.absorb(line)
36
+ @io.puts(JSON.generate(event)) if event
37
+ end
38
+ end
39
+ rescue Interrupt
40
+ nil
41
+ end
42
+ end
43
+ end
data/lib/txray.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "txray/version"
6
+ require_relative "txray/error"
7
+ require_relative "txray/node_helpers"
8
+ require_relative "txray/catalog"
9
+ require_relative "txray/rule"
10
+ require_relative "txray/config"
11
+ require_relative "txray/offense"
12
+ require_relative "txray/source_file"
13
+ require_relative "txray/method_index"
14
+ require_relative "txray/transaction_scope"
15
+ require_relative "txray/classifier"
16
+ require_relative "txray/client_index"
17
+ require_relative "txray/analyzer"
18
+ require_relative "txray/scanner"
19
+ require_relative "txray/monitor"
20
+ require_relative "txray/tail"
21
+ require_relative "txray/reporters"
22
+ require_relative "txray/watch_command"
23
+ require_relative "txray/runtime"
24
+ require_relative "txray/cli"
25
+
26
+ module Txray
27
+ def self.scan(paths = nil, config: Config.load)
28
+ Scanner.new(config, paths: paths).run
29
+ end
30
+
31
+ def self.analyze(code, path: "(string)", config: Config.new)
32
+ source = SourceFile.parse(path, code: code) or return []
33
+ Scanner.new(config).analyze([ source ]).sort_by(&:sort_key)
34
+ end
35
+ end
36
+
37
+ require_relative "txray/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: txray
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Theo Wecker
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: prism
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.24'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.24'
26
+ description: txray parses your Rails application with Prism and follows callbacks,
27
+ concerns and helper methods to find HTTP requests, external service clients, mail
28
+ delivery, job enqueues, subprocesses and unbounded loops running inside database
29
+ transactions. It needs no application boot and no test coverage, so it reports problems
30
+ on code paths your suite never executes. An optional runtime guard reports the same
31
+ problems from a running application.
32
+ email:
33
+ - tlwecker@yahoo.com
34
+ executables:
35
+ - txray
36
+ extensions: []
37
+ extra_rdoc_files: []
38
+ files:
39
+ - CHANGELOG.md
40
+ - LICENSE.txt
41
+ - README.md
42
+ - Rakefile
43
+ - exe/txray
44
+ - lib/txray.rb
45
+ - lib/txray/analyzer.rb
46
+ - lib/txray/catalog.rb
47
+ - lib/txray/classifier.rb
48
+ - lib/txray/cli.rb
49
+ - lib/txray/client_index.rb
50
+ - lib/txray/config.rb
51
+ - lib/txray/error.rb
52
+ - lib/txray/method_index.rb
53
+ - lib/txray/monitor.rb
54
+ - lib/txray/node_helpers.rb
55
+ - lib/txray/offense.rb
56
+ - lib/txray/railtie.rb
57
+ - lib/txray/reporters.rb
58
+ - lib/txray/reporters/github.rb
59
+ - lib/txray/reporters/json.rb
60
+ - lib/txray/reporters/live.rb
61
+ - lib/txray/reporters/sarif.rb
62
+ - lib/txray/reporters/text.rb
63
+ - lib/txray/rule.rb
64
+ - lib/txray/runtime.rb
65
+ - lib/txray/runtime/sink.rb
66
+ - lib/txray/runtime/transaction.rb
67
+ - lib/txray/scanner.rb
68
+ - lib/txray/source_file.rb
69
+ - lib/txray/tail.rb
70
+ - lib/txray/tasks.rake
71
+ - lib/txray/transaction_scope.rb
72
+ - lib/txray/version.rb
73
+ - lib/txray/watch_command.rb
74
+ homepage: https://github.com/theowecker/txray
75
+ licenses:
76
+ - MIT
77
+ metadata:
78
+ allowed_push_host: https://rubygems.org
79
+ homepage_uri: https://github.com/theowecker/txray
80
+ bug_tracker_uri: https://github.com/theowecker/txray/issues
81
+ changelog_uri: https://github.com/theowecker/txray/blob/main/CHANGELOG.md
82
+ rubygems_mfa_required: 'true'
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: 3.2.0
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 4.0.8
98
+ specification_version: 4
99
+ summary: Static analysis that finds slow work hidden inside database transactions.
100
+ test_files: []