rigortype 0.0.1

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.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +373 -0
  3. data/README.md +152 -0
  4. data/exe/rigor +9 -0
  5. data/lib/rigor/analysis/check_rules.rb +503 -0
  6. data/lib/rigor/analysis/diagnostic.rb +35 -0
  7. data/lib/rigor/analysis/fact_store.rb +133 -0
  8. data/lib/rigor/analysis/result.rb +29 -0
  9. data/lib/rigor/analysis/runner.rb +119 -0
  10. data/lib/rigor/ast/type_node.rb +41 -0
  11. data/lib/rigor/ast.rb +22 -0
  12. data/lib/rigor/cli/type_of_command.rb +160 -0
  13. data/lib/rigor/cli/type_of_renderer.rb +88 -0
  14. data/lib/rigor/cli/type_scan_command.rb +160 -0
  15. data/lib/rigor/cli/type_scan_renderer.rb +165 -0
  16. data/lib/rigor/cli/type_scan_report.rb +32 -0
  17. data/lib/rigor/cli.rb +195 -0
  18. data/lib/rigor/configuration.rb +49 -0
  19. data/lib/rigor/environment/class_registry.rb +141 -0
  20. data/lib/rigor/environment/rbs_hierarchy.rb +64 -0
  21. data/lib/rigor/environment/rbs_loader.rb +244 -0
  22. data/lib/rigor/environment.rb +177 -0
  23. data/lib/rigor/inference/acceptance.rb +444 -0
  24. data/lib/rigor/inference/block_parameter_binder.rb +198 -0
  25. data/lib/rigor/inference/closure_escape_analyzer.rb +191 -0
  26. data/lib/rigor/inference/coverage_scanner.rb +85 -0
  27. data/lib/rigor/inference/expression_typer.rb +831 -0
  28. data/lib/rigor/inference/fallback.rb +35 -0
  29. data/lib/rigor/inference/fallback_tracer.rb +64 -0
  30. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +102 -0
  31. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +169 -0
  32. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +421 -0
  33. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +336 -0
  34. data/lib/rigor/inference/method_dispatcher.rb +213 -0
  35. data/lib/rigor/inference/method_parameter_binder.rb +257 -0
  36. data/lib/rigor/inference/multi_target_binder.rb +143 -0
  37. data/lib/rigor/inference/narrowing.rb +1008 -0
  38. data/lib/rigor/inference/rbs_type_translator.rb +219 -0
  39. data/lib/rigor/inference/scope_indexer.rb +468 -0
  40. data/lib/rigor/inference/statement_evaluator.rb +1017 -0
  41. data/lib/rigor/rbs_extended.rb +98 -0
  42. data/lib/rigor/scope.rb +340 -0
  43. data/lib/rigor/source/node_locator.rb +104 -0
  44. data/lib/rigor/source/node_walker.rb +37 -0
  45. data/lib/rigor/source.rb +15 -0
  46. data/lib/rigor/testing.rb +65 -0
  47. data/lib/rigor/trinary.rb +108 -0
  48. data/lib/rigor/type/accepts_result.rb +109 -0
  49. data/lib/rigor/type/bot.rb +57 -0
  50. data/lib/rigor/type/combinator.rb +148 -0
  51. data/lib/rigor/type/constant.rb +90 -0
  52. data/lib/rigor/type/dynamic.rb +60 -0
  53. data/lib/rigor/type/hash_shape.rb +246 -0
  54. data/lib/rigor/type/nominal.rb +83 -0
  55. data/lib/rigor/type/singleton.rb +65 -0
  56. data/lib/rigor/type/top.rb +56 -0
  57. data/lib/rigor/type/tuple.rb +84 -0
  58. data/lib/rigor/type/union.rb +65 -0
  59. data/lib/rigor/type.rb +23 -0
  60. data/lib/rigor/version.rb +5 -0
  61. data/lib/rigor.rb +29 -0
  62. data/sig/rigor/analysis/fact_store.rbs +51 -0
  63. data/sig/rigor/ast.rbs +11 -0
  64. data/sig/rigor/environment.rbs +59 -0
  65. data/sig/rigor/inference.rbs +151 -0
  66. data/sig/rigor/rbs_extended.rbs +22 -0
  67. data/sig/rigor/scope.rbs +49 -0
  68. data/sig/rigor/source.rbs +20 -0
  69. data/sig/rigor/testing.rbs +9 -0
  70. data/sig/rigor/trinary.rbs +29 -0
  71. data/sig/rigor/type.rbs +171 -0
  72. data/sig/rigor.rbs +70 -0
  73. metadata +260 -0
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../environment"
6
+ require_relative "../scope"
7
+ require_relative "../inference/scope_indexer"
8
+ require_relative "check_rules"
9
+ require_relative "diagnostic"
10
+ require_relative "result"
11
+
12
+ module Rigor
13
+ module Analysis
14
+ class Runner
15
+ RUBY_GLOB = "**/*.rb"
16
+
17
+ def initialize(configuration:)
18
+ @configuration = configuration
19
+ end
20
+
21
+ # Walks every Ruby file under `paths`, parses it, builds a
22
+ # per-node scope index through
23
+ # `Rigor::Inference::ScopeIndexer`, and runs the
24
+ # `Rigor::Analysis::CheckRules` catalogue over it. Returns
25
+ # a `Rigor::Analysis::Result` aggregating every produced
26
+ # diagnostic plus any Prism parse errors. The Environment
27
+ # is built once at run start through `Environment.for_project`
28
+ # so all files share the same RBS load.
29
+ def run(paths = @configuration.paths)
30
+ environment = Environment.for_project
31
+ expansion = expand_paths(paths)
32
+
33
+ diagnostics = expansion.fetch(:errors)
34
+ diagnostics += expansion.fetch(:files).flat_map { |path| analyze_file(path, environment) }
35
+
36
+ Result.new(diagnostics: diagnostics)
37
+ end
38
+
39
+ private
40
+
41
+ # Resolves the user-supplied path list into:
42
+ # - `:files` — the concrete `.rb` files to analyze.
43
+ # - `:errors` — `Diagnostic` entries for each path that
44
+ # does not exist or is not a recognisable Ruby source.
45
+ #
46
+ # Surfacing path errors is a first-preview must-have:
47
+ # `rigor check ./does_not_exist.rb` previously exited
48
+ # cleanly with no output, which silently masked typos.
49
+ def expand_paths(paths)
50
+ files = []
51
+ errors = []
52
+ Array(paths).each do |path|
53
+ if File.directory?(path)
54
+ files.concat(Dir.glob(File.join(path, RUBY_GLOB)))
55
+ elsif File.file?(path) && path.end_with?(".rb")
56
+ files << path
57
+ elsif File.exist?(path)
58
+ errors << path_error(path, "not a Ruby file (expected `.rb` or a directory)")
59
+ else
60
+ errors << path_error(path, "no such file or directory")
61
+ end
62
+ end
63
+ { files: files, errors: errors }
64
+ end
65
+
66
+ def path_error(path, message)
67
+ Diagnostic.new(
68
+ path: path,
69
+ line: 1,
70
+ column: 1,
71
+ message: message,
72
+ severity: :error
73
+ )
74
+ end
75
+
76
+ def analyze_file(path, environment)
77
+ parse_result = Prism.parse_file(path)
78
+ return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
79
+
80
+ scope = Scope.empty(environment: environment)
81
+ index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
82
+ CheckRules.diagnose(path: path, root: parse_result.value, scope_index: index)
83
+ rescue Errno::ENOENT => e
84
+ [
85
+ Diagnostic.new(
86
+ path: path,
87
+ line: 1,
88
+ column: 1,
89
+ message: e.message,
90
+ severity: :error
91
+ )
92
+ ]
93
+ rescue StandardError => e
94
+ [
95
+ Diagnostic.new(
96
+ path: path,
97
+ line: 1,
98
+ column: 1,
99
+ message: "internal analyzer error: #{e.class}: #{e.message}",
100
+ severity: :error
101
+ )
102
+ ]
103
+ end
104
+
105
+ def parse_diagnostics(path, parse_result)
106
+ parse_result.errors.map do |error|
107
+ location = error.location
108
+ Diagnostic.new(
109
+ path: path,
110
+ line: location.start_line,
111
+ column: location.start_column + 1,
112
+ message: error.message,
113
+ severity: :error
114
+ )
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module AST
5
+ # A virtual node that wraps a Rigor::Type. Allows callers to ask
6
+ # "what would the analyzer infer at this position if the value's type
7
+ # were T?" without constructing a real Prism expression.
8
+ #
9
+ # Rigor::Scope#type_of(TypeNode.new(t)) MUST return a structurally-
10
+ # equal t. The engine MUST NOT modify or annotate the wrapped type.
11
+ #
12
+ # Inspired by PHPStan's TypeExpr (a synthetic Expr that returns a
13
+ # specific Type from $scope->getType). The Rigor counterpart is
14
+ # spelled "TypeNode" to align with Prism's "Node" suffix convention.
15
+ class TypeNode
16
+ include Node
17
+
18
+ attr_reader :type
19
+
20
+ def initialize(type)
21
+ raise ArgumentError, "TypeNode requires a non-nil Rigor::Type" if type.nil?
22
+
23
+ @type = type
24
+ freeze
25
+ end
26
+
27
+ def ==(other)
28
+ other.is_a?(TypeNode) && type == other.type
29
+ end
30
+ alias eql? ==
31
+
32
+ def hash
33
+ [TypeNode, type].hash
34
+ end
35
+
36
+ def inspect
37
+ "#<Rigor::AST::TypeNode #{type.describe(:short)}>"
38
+ end
39
+ end
40
+ end
41
+ end
data/lib/rigor/ast.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # Synthetic AST nodes accepted by Rigor::Scope#type_of alongside real
5
+ # Prism nodes. Rigor::AST::Node is a documentation-only marker module
6
+ # that production code uses to detect virtual-node arguments. Concrete
7
+ # virtual node classes include Rigor::AST::Node and provide whatever
8
+ # node-specific data the engine needs to translate them into a
9
+ # Rigor::Type.
10
+ #
11
+ # The contract for virtual nodes lives in
12
+ # docs/internal-spec/inference-engine.md; the rationale and the rejected
13
+ # alternative of specialising type classes for operator-method dispatch
14
+ # live in docs/adr/4-type-inference-engine.md.
15
+ module AST
16
+ # Marker module included by every synthetic node. Carries no behaviour.
17
+ module Node
18
+ end
19
+ end
20
+ end
21
+
22
+ require_relative "ast/type_node"
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optionparser"
4
+ require "prism"
5
+
6
+ require_relative "../environment"
7
+ require_relative "../scope"
8
+ require_relative "../source/node_locator"
9
+ require_relative "../inference/fallback_tracer"
10
+ require_relative "../inference/scope_indexer"
11
+ require_relative "type_of_renderer"
12
+
13
+ module Rigor
14
+ class CLI
15
+ # Executes the `rigor type-of` command.
16
+ #
17
+ # The command is a thin probe over `Rigor::Scope#type_of`: it locates the
18
+ # deepest expression at a `(file, line, column)` triple and prints the
19
+ # inferred type, RBS erasure, and (optionally) the recorded fail-soft
20
+ # fallbacks.
21
+ #
22
+ # Encapsulating the command in its own class keeps `Rigor::CLI` focused on
23
+ # dispatching and lets us evolve the type-of UX (extra flags, watch mode,
24
+ # streaming output) without bloating the CLI shell. Output formatting is
25
+ # delegated to {TypeOfRenderer}.
26
+ class TypeOfCommand
27
+ USAGE = "Usage: rigor type-of [options] FILE:LINE:COL"
28
+
29
+ Result = Data.define(:file, :line, :column, :node, :type, :tracer)
30
+
31
+ def initialize(argv:, out:, err:)
32
+ @argv = argv
33
+ @out = out
34
+ @err = err
35
+ end
36
+
37
+ # @return [Integer] CLI exit status.
38
+ def run
39
+ options = parse_options
40
+
41
+ target = parse_position_argument(@argv)
42
+ return CLI::EXIT_USAGE if target.nil?
43
+
44
+ execute(target: target, options: options)
45
+ end
46
+
47
+ private
48
+
49
+ def parse_options
50
+ options = { format: "text", trace: false }
51
+
52
+ parser = OptionParser.new do |opts|
53
+ opts.banner = USAGE
54
+ opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
55
+ opts.on("--trace", "Record fail-soft fallbacks via FallbackTracer") { options[:trace] = true }
56
+ end
57
+ parser.parse!(@argv)
58
+
59
+ options
60
+ end
61
+
62
+ def execute(target:, options:)
63
+ file, line, column = target
64
+ return 1 unless file_exists?(file)
65
+
66
+ source = File.read(file)
67
+ parse_result = Prism.parse(source, filepath: file)
68
+ return 1 if parse_errors?(parse_result, file)
69
+
70
+ node = locate_node(source: source, root: parse_result.value, file: file, line: line, column: column)
71
+ return CLI::EXIT_USAGE if node == :out_of_range
72
+ return 1 if node.nil?
73
+
74
+ tracer = options[:trace] ? Inference::FallbackTracer.new : nil
75
+ base_scope = Scope.empty(environment: project_environment(file))
76
+
77
+ # Build a per-node scope index so locals bound earlier in the
78
+ # file flow into the scope used to type the queried node. We
79
+ # build the index with no tracer attached (it would otherwise
80
+ # double-record fallback events with the second-pass type_of
81
+ # call below), then look up the scope visible at the queried
82
+ # node and run the actual probe under it.
83
+ scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
84
+ node_scope = scope_index[node]
85
+
86
+ type = node_scope.type_of(node, tracer: tracer)
87
+ result = Result.new(file: file, line: line, column: column, node: node, type: type, tracer: tracer)
88
+
89
+ TypeOfRenderer.new(out: @out).render(result, format: options.fetch(:format))
90
+ 0
91
+ end
92
+
93
+ # Builds a project-aware environment relative to the probed file.
94
+ # Project-RBS auto-detection roots at CWD today; future work will
95
+ # walk parent directories to find the enclosing `Gemfile`/`*.gemspec`
96
+ # so probes against files outside the current process's CWD still
97
+ # see the right `sig/` tree.
98
+ def project_environment(_file)
99
+ Environment.for_project
100
+ end
101
+
102
+ def file_exists?(file)
103
+ return true if File.file?(file)
104
+
105
+ @err.puts("type-of: file not found: #{file}")
106
+ false
107
+ end
108
+
109
+ def parse_errors?(result, file)
110
+ return false if result.errors.empty?
111
+
112
+ result.errors.each { |error| @err.puts("#{file}:#{error.location.start_line}: #{error.message}") }
113
+ true
114
+ end
115
+
116
+ def locate_node(source:, root:, file:, line:, column:)
117
+ node = Source::NodeLocator.at_position(source: source, root: root, line: line, column: column)
118
+ @err.puts("type-of: no expression found at #{file}:#{line}:#{column}") if node.nil?
119
+ node
120
+ rescue Source::NodeLocator::OutOfRangeError => e
121
+ @err.puts("type-of: #{e.message}")
122
+ :out_of_range
123
+ end
124
+
125
+ def parse_position_argument(argv)
126
+ case argv.size
127
+ when 1
128
+ parse_colon_form(argv[0])
129
+ when 3
130
+ decode_position(*argv)
131
+ else
132
+ @err.puts("type-of: expected FILE:LINE:COL or FILE LINE COL")
133
+ @err.puts(USAGE)
134
+ nil
135
+ end
136
+ end
137
+
138
+ def parse_colon_form(arg)
139
+ parts = arg.split(":")
140
+ if parts.size < 3
141
+ @err.puts("type-of: expected FILE:LINE:COL, got #{arg.inspect}")
142
+ @err.puts(USAGE)
143
+ return nil
144
+ end
145
+
146
+ column = parts.pop
147
+ line = parts.pop
148
+ file = parts.join(":")
149
+ decode_position(file, line, column)
150
+ end
151
+
152
+ def decode_position(file, line, column)
153
+ [file, Integer(line, 10), Integer(column, 10)]
154
+ rescue ArgumentError
155
+ @err.puts("type-of: line and column must be integers")
156
+ nil
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optionparser"
5
+
6
+ module Rigor
7
+ class CLI
8
+ # Renders a `TypeOfCommand::Result` as either human-readable text or a
9
+ # machine-readable JSON document.
10
+ #
11
+ # The renderer is a separate concern from the command itself so that future
12
+ # output formats (sexp, lsp-style hover payloads, color decoration) can
13
+ # plug in without disturbing argument parsing or the inference call site.
14
+ class TypeOfRenderer
15
+ def initialize(out:)
16
+ @out = out
17
+ end
18
+
19
+ def render(result, format:)
20
+ case format
21
+ when "text" then render_text(result)
22
+ when "json" then render_json(result)
23
+ else
24
+ raise OptionParser::InvalidArgument, "unsupported format: #{format}"
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def render_text(result)
31
+ @out.puts("#{result.file}:#{result.line}:#{result.column}")
32
+ @out.puts("node: #{result.node.class}")
33
+ @out.puts("type: #{result.type.describe}")
34
+ @out.puts("erased: #{result.type.erase_to_rbs}")
35
+ render_text_fallbacks(result)
36
+ end
37
+
38
+ def render_text_fallbacks(result)
39
+ tracer = result.tracer
40
+ return if tracer.nil?
41
+
42
+ if tracer.empty?
43
+ @out.puts("fallbacks: none")
44
+ else
45
+ @out.puts("fallbacks (#{tracer.size}):")
46
+ tracer.each { |event| @out.puts(" - #{format_fallback_text(event, result.file)}") }
47
+ end
48
+ end
49
+
50
+ def render_json(result)
51
+ payload = {
52
+ file: result.file,
53
+ line: result.line,
54
+ column: result.column,
55
+ node: result.node.class.name,
56
+ type: result.type.describe,
57
+ erased: result.type.erase_to_rbs
58
+ }
59
+ payload[:fallbacks] = result.tracer.map { |event| fallback_to_h(event) } if result.tracer
60
+ @out.puts(JSON.pretty_generate(payload))
61
+ end
62
+
63
+ def format_fallback_text(event, file)
64
+ "#{event.node_class} (#{event.family}) @ #{location_text(event.location, file)}"
65
+ end
66
+
67
+ def location_text(location, file)
68
+ return "<no location>" unless location.respond_to?(:start_line)
69
+
70
+ "#{file}:#{location.start_line}:#{location.start_column + 1}"
71
+ end
72
+
73
+ def fallback_to_h(event)
74
+ hash = {
75
+ node_class: event.node_class.name,
76
+ family: event.family,
77
+ inner_type: event.inner_type.describe
78
+ }
79
+ location = event.location
80
+ if location.respond_to?(:start_line)
81
+ hash[:line] = location.start_line
82
+ hash[:column] = location.start_column + 1
83
+ end
84
+ hash
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optionparser"
4
+ require "prism"
5
+
6
+ require_relative "../environment"
7
+ require_relative "../inference/coverage_scanner"
8
+ require_relative "../scope"
9
+ require_relative "type_scan_renderer"
10
+ require_relative "type_scan_report"
11
+
12
+ module Rigor
13
+ class CLI
14
+ # Executes the `rigor type-scan` command.
15
+ #
16
+ # The command walks every Prism node in one or more files, runs
17
+ # `Rigor::Scope#type_of` on each, and reports per-node-class coverage of
18
+ # the inference engine's directly recognized classes. It is the project's
19
+ # primary CI gate for tracking how much of an input source the engine can
20
+ # name without falling back to `Dynamic[Top]`.
21
+ class TypeScanCommand
22
+ USAGE = "Usage: rigor type-scan [options] PATH..."
23
+
24
+ LocatedEvent = Data.define(:file, :event)
25
+
26
+ def initialize(argv:, out:, err:)
27
+ @argv = argv
28
+ @out = out
29
+ @err = err
30
+ end
31
+
32
+ # @return [Integer] CLI exit status.
33
+ def run
34
+ options = parse_options
35
+ paths = collect_paths(@argv)
36
+ return CLI::EXIT_USAGE if paths.nil?
37
+ return usage_error if paths.empty?
38
+
39
+ report = scan_paths(paths, options)
40
+ TypeScanRenderer.new(out: @out).render(report, format: options.fetch(:format))
41
+ determine_exit(report, options)
42
+ end
43
+
44
+ private
45
+
46
+ def parse_options
47
+ options = { format: "text", limit: 10, show_recognized: false, threshold: nil }
48
+
49
+ parser = OptionParser.new do |opts|
50
+ opts.banner = USAGE
51
+ opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
52
+ opts.on("--limit=N", Integer, "Max example events to print (text only)") do |value|
53
+ options[:limit] = value
54
+ end
55
+ opts.on("--show-recognized", "Include classes with 0 unrecognized in the table") do
56
+ options[:show_recognized] = true
57
+ end
58
+ opts.on("--threshold=RATIO", Float, "Exit non-zero when unrecognized/visits > RATIO") do |value|
59
+ options[:threshold] = value
60
+ end
61
+ end
62
+ parser.parse!(@argv)
63
+
64
+ options
65
+ end
66
+
67
+ def collect_paths(args)
68
+ paths = []
69
+ args.each do |arg|
70
+ if File.directory?(arg)
71
+ paths.concat(Dir.glob(File.join(arg, "**/*.rb")))
72
+ elsif File.file?(arg)
73
+ paths << arg
74
+ else
75
+ @err.puts("type-scan: not a file or directory: #{arg}")
76
+ return nil
77
+ end
78
+ end
79
+ paths.uniq
80
+ end
81
+
82
+ def usage_error
83
+ @err.puts("type-scan: at least one path is required")
84
+ @err.puts(USAGE)
85
+ CLI::EXIT_USAGE
86
+ end
87
+
88
+ def scan_paths(paths, options)
89
+ scope = Scope.empty(environment: project_environment)
90
+ scanner = Inference::CoverageScanner.new(scope: scope)
91
+ accumulator = ScanAccumulator.new
92
+ paths.each { |path| scan_one(path, scanner, accumulator) }
93
+ accumulator.to_report(paths, options)
94
+ end
95
+
96
+ # Builds a project-aware environment that auto-detects `<cwd>/sig`
97
+ # so calls scoped to the current project resolve through the
98
+ # local RBS tree. Phase 2a does not yet wire stdlib opt-in here;
99
+ # that lands when the configuration layer (`.rigor.yml`) gains an
100
+ # `rbs:` section.
101
+ def project_environment
102
+ Environment.for_project
103
+ end
104
+
105
+ def scan_one(path, scanner, accumulator)
106
+ source = File.read(path)
107
+ parse_result = Prism.parse(source, filepath: path)
108
+ if parse_result.errors.any?
109
+ accumulator.record_parse_error(path, parse_result.errors)
110
+ return
111
+ end
112
+
113
+ accumulator.absorb(path, scanner.scan(parse_result.value))
114
+ end
115
+
116
+ def determine_exit(report, options)
117
+ return 1 unless report.parse_errors.empty?
118
+
119
+ threshold = options[:threshold]
120
+ return 0 if threshold.nil?
121
+
122
+ report.unrecognized_ratio > threshold ? 1 : 0
123
+ end
124
+
125
+ # Internal helper that accumulates per-file scan results into the
126
+ # totals carried by `Report`.
127
+ class ScanAccumulator
128
+ def initialize
129
+ @visits = Hash.new(0)
130
+ @unrecognized = Hash.new(0)
131
+ @events = []
132
+ @parse_errors = []
133
+ end
134
+
135
+ def absorb(path, file_result)
136
+ file_result.visits.each { |klass, count| @visits[klass] += count }
137
+ file_result.unrecognized.each { |klass, count| @unrecognized[klass] += count }
138
+ file_result.events.each do |event|
139
+ @events << LocatedEvent.new(file: path, event: event)
140
+ end
141
+ end
142
+
143
+ def record_parse_error(path, errors)
144
+ @parse_errors << { file: path, errors: errors.map(&:message) }
145
+ end
146
+
147
+ def to_report(paths, options)
148
+ Report.new(
149
+ files: paths,
150
+ parse_errors: @parse_errors,
151
+ visits: @visits,
152
+ unrecognized: @unrecognized,
153
+ events: @events,
154
+ options: options
155
+ )
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end