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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +274 -0
- data/Rakefile +9 -0
- data/exe/txray +6 -0
- data/lib/txray/analyzer.rb +109 -0
- data/lib/txray/catalog.rb +89 -0
- data/lib/txray/classifier.rb +102 -0
- data/lib/txray/cli.rb +98 -0
- data/lib/txray/client_index.rb +119 -0
- data/lib/txray/config.rb +75 -0
- data/lib/txray/error.rb +5 -0
- data/lib/txray/method_index.rb +124 -0
- data/lib/txray/monitor.rb +68 -0
- data/lib/txray/node_helpers.rb +62 -0
- data/lib/txray/offense.rb +49 -0
- data/lib/txray/railtie.rb +26 -0
- data/lib/txray/reporters/github.rb +21 -0
- data/lib/txray/reporters/json.rb +22 -0
- data/lib/txray/reporters/live.rb +179 -0
- data/lib/txray/reporters/sarif.rb +52 -0
- data/lib/txray/reporters/text.rb +47 -0
- data/lib/txray/reporters.rb +18 -0
- data/lib/txray/rule.rb +113 -0
- data/lib/txray/runtime/sink.rb +40 -0
- data/lib/txray/runtime/transaction.rb +49 -0
- data/lib/txray/runtime.rb +197 -0
- data/lib/txray/scanner.rb +70 -0
- data/lib/txray/source_file.rb +43 -0
- data/lib/txray/tail.rb +43 -0
- data/lib/txray/tasks.rake +9 -0
- data/lib/txray/transaction_scope.rb +125 -0
- data/lib/txray/version.rb +5 -0
- data/lib/txray/watch_command.rb +43 -0
- data/lib/txray.rb +37 -0
- metadata +100 -0
|
@@ -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,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,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: []
|