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.
data/lib/txray/cli.rb ADDED
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Txray
6
+ class CLI
7
+ EXIT_CLEAN = 0
8
+ EXIT_OFFENSES = 1
9
+ EXIT_ERROR = 2
10
+
11
+ def self.start(argv, io: $stdout) = new(io: io).run(argv)
12
+
13
+ def initialize(io: $stdout)
14
+ @io = io
15
+ @options = {}
16
+ end
17
+
18
+ def run(argv)
19
+ paths = parser.parse(argv)
20
+ return init_config if @options[:init]
21
+ return watch if paths.first == "watch"
22
+
23
+ config = Config.load(@options[:config]).merge(
24
+ "fail_level" => @options[:fail_level],
25
+ "disabled_rules" => @options[:disabled_rules],
26
+ "max_depth" => @options[:max_depth]
27
+ )
28
+
29
+ result = Scanner.new(config, paths: paths).run
30
+ result = filter(result)
31
+ Reporters.build(@options.fetch(:format, "text"), io: @io).report(result)
32
+ result.failing?(config.fail_level) ? EXIT_OFFENSES : EXIT_CLEAN
33
+ rescue OptionParser::ParseError, Error => e
34
+ @io.puts "txray: #{e.message}"
35
+ EXIT_ERROR
36
+ end
37
+
38
+ private
39
+
40
+ def filter(result)
41
+ return result unless @options[:only]
42
+
43
+ Result.new(offenses: result.offenses.select { |offense| @options[:only].include?(offense.id) },
44
+ files: result.files, skipped: result.skipped)
45
+ end
46
+
47
+ def watch
48
+ runtime = Config.load(@options[:config]).runtime
49
+ WatchCommand.new(
50
+ path: @options[:file] || runtime["log_path"],
51
+ threshold_ms: (@options[:threshold] || runtime["threshold_ms"]).to_i,
52
+ io: @io,
53
+ from_start: @options[:from_start] == true
54
+ ).run
55
+ EXIT_CLEAN
56
+ end
57
+
58
+ def init_config
59
+ if File.exist?(Config::FILENAME)
60
+ @io.puts "#{Config::FILENAME} already exists"
61
+ return EXIT_ERROR
62
+ end
63
+
64
+ File.write(Config::FILENAME, YAML.dump(Config::DEFAULTS))
65
+ @io.puts "created #{Config::FILENAME}"
66
+ EXIT_CLEAN
67
+ end
68
+
69
+ def parser
70
+ OptionParser.new do |opts|
71
+ opts.banner = "Usage: txray [options] [paths]\n txray watch [options]"
72
+ opts.on("-f", "--format FORMAT", "text, json, sarif or github (default: text)") { |v| @options[:format] = v }
73
+ opts.on("-c", "--config PATH", "path to a .txray.yml file") { |v| @options[:config] = v }
74
+ opts.on("--fail-level LEVEL", "low, medium, high or none (default: low)") { |v| @options[:fail_level] = v }
75
+ opts.on("--only RULES", Array, "report only these rule ids") { |v| @options[:only] = v }
76
+ opts.on("--except RULES", Array, "skip these rule ids") { |v| @options[:disabled_rules] = v }
77
+ opts.on("--depth N", Integer, "how far to follow method calls (default: 3)") { |v| @options[:max_depth] = v }
78
+ opts.on("--rules", "list every rule and exit") { list_rules }
79
+ opts.on("--init", "write a default .txray.yml") { @options[:init] = true }
80
+ opts.on("--file PATH", "watch: event log written by the runtime guard") { |v| @options[:file] = v }
81
+ opts.on("--threshold MS", Integer, "watch: slow transaction threshold") { |v| @options[:threshold] = v }
82
+ opts.on("--from-start", "watch: replay the existing log first") { @options[:from_start] = true }
83
+ opts.on("-v", "--version", "print the version") { print_and_exit(VERSION) }
84
+ opts.on("-h", "--help", "print this help") { print_and_exit(opts.to_s) }
85
+ end
86
+ end
87
+
88
+ def list_rules
89
+ Rules.all.each_value { |rule| @io.puts "#{rule.id.ljust(34)} #{rule.severity.to_s.ljust(7)} #{rule.remedy}" }
90
+ exit EXIT_CLEAN
91
+ end
92
+
93
+ def print_and_exit(text)
94
+ @io.puts text
95
+ exit EXIT_CLEAN
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txray
4
+ class ClientIndex
5
+ RETURN_WRITES = [
6
+ Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableWriteNode,
7
+ Prism::LocalVariableOrWriteNode, Prism::LocalVariableWriteNode,
8
+ Prism::ConstantOrWriteNode, Prism::ConstantWriteNode
9
+ ].freeze
10
+
11
+ def initialize(classifier)
12
+ @classifier = classifier
13
+ @locals = {}
14
+ @ivars = {}
15
+ @constants = {}
16
+ @methods = {}
17
+ @delegations = {}
18
+ end
19
+
20
+ def index(source)
21
+ Namespaces.each(source.root) do |namespace, node|
22
+ case node
23
+ when Prism::LocalVariableWriteNode then bind(@locals, source.path, node.name, node.value)
24
+ when Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode
25
+ bind(@ivars, namespace, node.name, node.value)
26
+ when Prism::ConstantWriteNode then bind_constant(namespace, node)
27
+ when Prism::DefNode then bind_method(namespace, node)
28
+ when Prism::CallNode then bind_delegation(namespace, node)
29
+ end
30
+ end
31
+ end
32
+
33
+ def kind_of(node, context, depth = 0)
34
+ return nil if node.nil? || depth > 4
35
+
36
+ case node
37
+ when Prism::LocalVariableReadNode then @locals.dig(context.path, node.name)
38
+ when Prism::InstanceVariableReadNode then @ivars.dig(context.namespace, node.name)
39
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then constant_kind(node, context.namespace)
40
+ when Prism::CallNode then call_kind(node, context, depth)
41
+ end
42
+ end
43
+
44
+ def delegated_kind(namespace, name, depth = 0)
45
+ target = @delegations.dig(namespace, name)
46
+ return nil if target.nil? || depth > 4
47
+
48
+ @methods.dig(namespace, target) || delegated_kind(namespace, target, depth + 1)
49
+ end
50
+
51
+ private
52
+
53
+ def call_kind(node, context, depth)
54
+ return @methods.dig(context.namespace, node.name) if node.receiver.nil?
55
+
56
+ constructed_kind(node) || kind_of(node.receiver, context, depth + 1)
57
+ end
58
+
59
+ def constructed_kind(node)
60
+ @classifier.constructor?(node) ? @classifier.constant_rule(NodeHelpers.constant_name(node.receiver)) : nil
61
+ end
62
+
63
+ def constant_kind(node, namespace)
64
+ name = NodeHelpers.constant_name(node).to_s
65
+ @constants[qualify(namespace, name)] || @constants[name]
66
+ end
67
+
68
+ def qualify(namespace, name) = namespace.to_s.empty? ? name.to_s : "#{namespace}::#{name}"
69
+
70
+ def bind(table, scope, name, value)
71
+ kind = source_kind(value)
72
+ (table[scope] ||= {})[name] = kind if kind
73
+ end
74
+
75
+ def bind_constant(namespace, node)
76
+ kind = source_kind(node.value)
77
+ @constants[qualify(namespace, node.name)] = kind if kind
78
+ end
79
+
80
+ def bind_method(namespace, node)
81
+ kind = returned_kind(node.body)
82
+ (@methods[namespace] ||= {})[node.name] = kind if kind
83
+ end
84
+
85
+ def returned_kind(body)
86
+ node = body.is_a?(Prism::StatementsNode) ? body.body.last : body
87
+ return source_kind(node.value) if RETURN_WRITES.any? { |type| node.is_a?(type) }
88
+
89
+ source_kind(node)
90
+ end
91
+
92
+ def bind_delegation(namespace, call)
93
+ return unless call.name == :delegate && call.receiver.nil?
94
+
95
+ target = delegation_target(call)
96
+ return if target.nil?
97
+
98
+ NodeHelpers.symbol_arguments(call).each { |name| (@delegations[namespace] ||= {})[name] = target }
99
+ end
100
+
101
+ def delegation_target(call)
102
+ hash = NodeHelpers.positional_arguments(call).grep(Prism::KeywordHashNode).first
103
+ return nil if hash.nil?
104
+
105
+ value = hash.elements.grep(Prism::AssocNode).find { |element| key_name(element) == "to" }&.value
106
+ value.unescaped.to_sym if value.is_a?(Prism::SymbolNode)
107
+ end
108
+
109
+ def key_name(element)
110
+ element.key.unescaped if element.key.respond_to?(:unescaped)
111
+ end
112
+
113
+ def source_kind(node)
114
+ return nil unless node.is_a?(Prism::CallNode) && @classifier.constructor?(node)
115
+
116
+ @classifier.constant_rule(NodeHelpers.constant_name(node.receiver))
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+
6
+ module Txray
7
+ class Config
8
+ FILENAME = ".txray.yml"
9
+
10
+ DEFAULTS = {
11
+ "include" => [ "app", "lib", "db/migrate" ],
12
+ "exclude" => %w[spec test vendor node_modules tmp log .git],
13
+ "max_depth" => 3,
14
+ "fail_level" => "low",
15
+ "disabled_rules" => [],
16
+ "external_clients" => [],
17
+ "severities" => {},
18
+ "runtime" => {
19
+ "enabled" => false,
20
+ "threshold_ms" => 250,
21
+ "on_violation" => "log",
22
+ "log_path" => "tmp/txray.ndjson",
23
+ "ignore" => [],
24
+ "guard_http" => true,
25
+ "guard_jobs" => true,
26
+ "guard_mail" => true
27
+ }
28
+ }.freeze
29
+
30
+ def self.discover(from = Dir.pwd)
31
+ Pathname.new(from).ascend do |directory|
32
+ candidate = directory.join(FILENAME)
33
+ return candidate.to_s if candidate.file?
34
+ end
35
+ nil
36
+ end
37
+
38
+ def self.load(path = nil)
39
+ raise Error, "no such config file: #{path}" if path && !File.exist?(path)
40
+
41
+ path ||= discover
42
+ data = path ? YAML.safe_load_file(path) : {}
43
+ new(data.is_a?(Hash) ? data : {})
44
+ end
45
+
46
+ def initialize(data = {})
47
+ @data = DEFAULTS.merge(data) { |_key, default, given| default.is_a?(Hash) ? default.merge(given.to_h) : given }
48
+ end
49
+
50
+ def includes = Array(@data["include"])
51
+ def excludes = Array(@data["exclude"])
52
+ def max_depth = Integer(@data["max_depth"])
53
+ def external_clients = Array(@data["external_clients"]).map(&:to_s)
54
+ def disabled_rules = Array(@data["disabled_rules"]).map(&:to_s)
55
+ def runtime = @data["runtime"].transform_keys(&:to_s)
56
+
57
+ def fail_level = @data["fail_level"].to_s.to_sym
58
+
59
+ def rule_enabled?(id) = !disabled_rules.include?(id.to_s)
60
+
61
+ def rule(id)
62
+ severity = @data["severities"].to_h[id.to_s]
63
+ base = Rules[id]
64
+ return base unless severity
65
+
66
+ base.dup.tap { |copy| copy.severity = severity.to_sym }
67
+ end
68
+
69
+ def merge(overrides)
70
+ self.class.new(@data.merge(overrides.compact.transform_keys(&:to_s)))
71
+ end
72
+
73
+ def to_h = @data
74
+ end
75
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txray
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txray
4
+ MethodEntry = Struct.new(:namespace, :name, :node, :source, :singleton, keyword_init: true) do
5
+ def label = "#{namespace}#{singleton ? "." : "#"}#{name}"
6
+ def line = node.location.start_line
7
+ def path = source.path
8
+ end
9
+
10
+ class MethodIndex
11
+ MAX_RESOLUTION_DEPTH = 4
12
+
13
+ def initialize
14
+ @instance = {}
15
+ @singleton = {}
16
+ @includes = {}
17
+ end
18
+
19
+ def index(source)
20
+ Namespaces.each(source.root) do |namespace, node|
21
+ case node
22
+ when Prism::DefNode then add_method(namespace, node, source)
23
+ when Prism::CallNode
24
+ add_include(namespace, node)
25
+ add_defined_method(namespace, node, source)
26
+ end
27
+ end
28
+ end
29
+
30
+ def unique(name)
31
+ matches = @instance.each_value.filter_map { |methods| methods[name] }
32
+ matches.first if matches.size == 1
33
+ end
34
+
35
+ def lookup(namespace, name, singleton: false, depth: 0)
36
+ return nil if namespace.nil? || namespace.empty? || depth > MAX_RESOLUTION_DEPTH
37
+
38
+ table = singleton ? @singleton : @instance
39
+ direct = table.dig(namespace, name)
40
+ return direct if direct
41
+
42
+ from_modules(namespace, name, singleton, depth) || from_lexical_parent(namespace, name, singleton, depth)
43
+ end
44
+
45
+ private
46
+
47
+ def add_method(namespace, node, source)
48
+ singleton = !node.receiver.nil?
49
+ table = singleton ? @singleton : @instance
50
+ entry = MethodEntry.new(namespace: namespace, name: node.name.to_sym, node: node, source: source,
51
+ singleton: singleton)
52
+ (table[namespace] ||= {})[entry.name] ||= entry
53
+ end
54
+
55
+ def add_defined_method(namespace, call, source)
56
+ return unless call.receiver.nil? && call.name == :define_method && call.block.is_a?(Prism::BlockNode)
57
+
58
+ name = NodeHelpers.symbol_arguments(call).first
59
+ return if name.nil?
60
+
61
+ entry = MethodEntry.new(namespace: namespace, name: name, node: call.block, source: source, singleton: false)
62
+ (@instance[namespace] ||= {})[name] ||= entry
63
+ end
64
+
65
+ def add_include(namespace, node)
66
+ return unless node.receiver.nil? && %i[include prepend extend].include?(node.name)
67
+
68
+ NodeHelpers.positional_arguments(node).each do |argument|
69
+ name = NodeHelpers.constant_name(argument)
70
+ (@includes[namespace] ||= []) << name if name
71
+ end
72
+ end
73
+
74
+ def from_modules(namespace, name, singleton, depth)
75
+ @includes.fetch(namespace, []).each do |mod|
76
+ resolved = resolve_namespace(namespace, mod)
77
+ entry = resolved && lookup(resolved, name, singleton: singleton, depth: depth + 1)
78
+ return entry if entry
79
+ end
80
+ nil
81
+ end
82
+
83
+ def from_lexical_parent(namespace, name, singleton, depth)
84
+ parent = namespace.split("::")[0..-2].join("::")
85
+ parent.empty? ? nil : lookup(parent, name, singleton: singleton, depth: depth + 1)
86
+ end
87
+
88
+ def resolve_namespace(from, mod)
89
+ segments = from.split("::")
90
+ candidates = segments.each_index.map { |i| (segments[0..i] + [ mod ]).join("::") }.reverse
91
+ (candidates + [ mod ]).find { |candidate| known?(candidate) }
92
+ end
93
+
94
+ def known?(namespace)
95
+ @instance.key?(namespace) || @singleton.key?(namespace) || @includes.key?(namespace)
96
+ end
97
+ end
98
+
99
+ module Namespaces
100
+ module_function
101
+
102
+ def each(node, namespace = "", &block)
103
+ return if node.nil?
104
+
105
+ case node
106
+ when Prism::ClassNode, Prism::ModuleNode
107
+ child = join(namespace, NodeHelpers.constant_name(node.constant_path))
108
+ block.call(child, node)
109
+ each(node.body, child, &block)
110
+ when Prism::SingletonClassNode
111
+ each(node.body, namespace, &block)
112
+ else
113
+ block.call(namespace, node)
114
+ node.compact_child_nodes.each { |c| each(c, namespace, &block) }
115
+ end
116
+ end
117
+
118
+ def join(namespace, name)
119
+ return namespace if name.nil?
120
+
121
+ namespace.empty? ? name : "#{namespace}::#{name}"
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Txray
6
+ class Monitor
7
+ CAP = 2000
8
+
9
+ attr_reader :transactions, :violations, :started_at
10
+
11
+ def initialize(threshold_ms: 250)
12
+ @threshold_ms = threshold_ms
13
+ @transactions = []
14
+ @violations = []
15
+ @recent = []
16
+ @started_at = Time.now
17
+ end
18
+
19
+ def absorb(line)
20
+ event = JSON.parse(line, symbolize_names: true)
21
+ case event[:type]
22
+ when "transaction" then add(@transactions, event)
23
+ when "violation" then add(@violations, event)
24
+ else return nil
25
+ end
26
+ @recent.unshift(event)
27
+ @recent.pop while @recent.size > 40
28
+ event
29
+ rescue JSON::ParserError
30
+ nil
31
+ end
32
+
33
+ def recent(limit) = @recent.first(limit)
34
+ def durations = @transactions.map { |event| event[:duration_ms].to_f }
35
+ def slow = @transactions.count { |event| event[:duration_ms].to_f >= @threshold_ms }
36
+ def flagged = @transactions.count { |event| event[:violations].to_a.any? }
37
+ def uptime = Time.now - @started_at
38
+ def empty? = @transactions.empty? && @violations.empty?
39
+
40
+ def percentile(fraction)
41
+ values = durations.sort
42
+ return 0.0 if values.empty?
43
+
44
+ values[[ (values.size * fraction).ceil - 1, 0 ].max]
45
+ end
46
+
47
+ def findings
48
+ nested = @transactions.flat_map { |event| event[:violations].to_a }
49
+ slow = @transactions.select { |event| event[:duration_ms].to_f >= @threshold_ms }
50
+ .map { |event| { rule: "slow-transaction", source: event[:source] } }
51
+ @violations + nested + slow
52
+ end
53
+
54
+ def hotspots(limit)
55
+ findings.group_by { |event| [ event[:rule], event[:source] ] }
56
+ .map { |(rule, source), events| { rule: rule, source: source, count: events.size } }
57
+ .sort_by { |entry| -entry[:count] }
58
+ .first(limit)
59
+ end
60
+
61
+ private
62
+
63
+ def add(collection, event)
64
+ collection << event
65
+ collection.shift while collection.size > CAP
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txray
4
+ module NodeHelpers
5
+ module_function
6
+
7
+ def each_node(node, &block)
8
+ return if node.nil?
9
+
10
+ block.call(node)
11
+ node.compact_child_nodes.each { |child| each_node(child, &block) }
12
+ end
13
+
14
+ def constant_name(node)
15
+ case node
16
+ when Prism::ConstantReadNode
17
+ node.name.to_s
18
+ when Prism::ConstantPathNode
19
+ [ constant_name(node.parent), constant_path_child(node) ].compact.reject(&:empty?).join("::")
20
+ when Prism::SelfNode
21
+ "self"
22
+ end
23
+ end
24
+
25
+ def constant_path_child(node)
26
+ return node.name.to_s if node.respond_to?(:name) && node.name
27
+ return constant_name(node.child) if node.respond_to?(:child)
28
+
29
+ nil
30
+ end
31
+
32
+ def symbol_arguments(call)
33
+ positional_arguments(call).grep(Prism::SymbolNode).map { |symbol| symbol.unescaped.to_sym }
34
+ end
35
+
36
+ def positional_arguments(call)
37
+ call.arguments&.arguments.to_a
38
+ end
39
+
40
+ def block_body(call)
41
+ call.block.body if call.block.is_a?(Prism::BlockNode)
42
+ end
43
+
44
+ def receiver_name(call)
45
+ constant_name(call.receiver) || dynamic_receiver_name(call.receiver)
46
+ end
47
+
48
+ def dynamic_receiver_name(node)
49
+ case node
50
+ when Prism::CallNode
51
+ [ receiver_name(node), node.name ].compact.join(".")
52
+ when Prism::InstanceVariableReadNode, Prism::GlobalVariableReadNode, Prism::LocalVariableReadNode
53
+ node.name.to_s
54
+ end
55
+ end
56
+
57
+ def snippet(node, limit = 90)
58
+ text = node.slice.to_s.lines.first.to_s.strip
59
+ text.length > limit ? "#{text[0, limit - 3]}..." : text
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txray
4
+ Frame = Struct.new(:label, :path, :line, keyword_init: true) do
5
+ def to_s = "#{label} (#{path}:#{line})"
6
+ end
7
+
8
+ class Offense
9
+ SEVERITIES = %i[low medium high].freeze
10
+
11
+ attr_reader :rule, :path, :line, :column, :snippet, :scope, :trace
12
+
13
+ def initialize(rule:, path:, line:, column:, snippet:, scope:, trace: [])
14
+ @rule = rule
15
+ @path = path
16
+ @line = line
17
+ @column = column
18
+ @snippet = snippet
19
+ @scope = scope
20
+ @trace = trace
21
+ end
22
+
23
+ def id = rule.id
24
+ def severity = rule.severity
25
+ def severity_rank = SEVERITIES.index(severity) || 0
26
+ def location = "#{path}:#{line}:#{column}"
27
+ def message = format(rule.message, snippet: snippet, scope: scope.label)
28
+
29
+ def key = [ rule.id, path, line, column ]
30
+
31
+ def sort_key = [ -severity_rank, path, line, column ]
32
+
33
+ def to_h
34
+ {
35
+ rule: rule.id,
36
+ severity: severity,
37
+ category: rule.category,
38
+ path: path,
39
+ line: line,
40
+ column: column,
41
+ message: message,
42
+ snippet: snippet,
43
+ scope: { kind: scope.kind, label: scope.label, path: scope.path, line: scope.line },
44
+ trace: trace.map { |frame| { label: frame.label, path: frame.path, line: frame.line } },
45
+ remedy: rule.remedy
46
+ }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txray
4
+ class Railtie < ::Rails::Railtie
5
+ config.txray = ActiveSupport::OrderedOptions.new
6
+
7
+ rake_tasks { load File.expand_path("tasks.rake", __dir__) }
8
+
9
+ initializer "txray.runtime" do |app|
10
+ settings = Config.load.runtime.merge(app.config.txray.to_h.transform_keys(&:to_s))
11
+ next unless settings["enabled"]
12
+
13
+ ActiveSupport.on_load(:active_record) do
14
+ Txray::Runtime.install(
15
+ threshold_ms: settings["threshold_ms"],
16
+ on_violation: settings["on_violation"],
17
+ guard_http: settings["guard_http"],
18
+ guard_jobs: settings["guard_jobs"],
19
+ guard_mail: settings["guard_mail"],
20
+ log_path: settings["log_path"],
21
+ ignore: settings["ignore"]
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txray
4
+ module Reporters
5
+ class Github
6
+ LEVELS = { high: "error", medium: "warning", low: "notice" }.freeze
7
+
8
+ def initialize(io: $stdout)
9
+ @io = io
10
+ end
11
+
12
+ def report(result)
13
+ result.offenses.each do |offense|
14
+ level = LEVELS.fetch(offense.severity, "warning")
15
+ title = "txray: #{offense.id}"
16
+ @io.puts "::#{level} file=#{offense.path},line=#{offense.line},col=#{offense.column},title=#{title}::#{offense.message}"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Txray
6
+ module Reporters
7
+ class Json
8
+ def initialize(io: $stdout)
9
+ @io = io
10
+ end
11
+
12
+ def report(result)
13
+ @io.puts JSON.pretty_generate(
14
+ version: Txray::VERSION,
15
+ summary: { files: result.files.size, offenses: result.offenses.size, severities: result.counts,
16
+ skipped: result.skipped.to_a },
17
+ offenses: result.offenses.map(&:to_h)
18
+ )
19
+ end
20
+ end
21
+ end
22
+ end