rigortype 0.1.17 → 0.1.19

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -222
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
  9. data/lib/rigor/analysis/check_rules.rb +275 -44
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
  12. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  13. data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
  14. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  15. data/lib/rigor/analysis/runner.rb +207 -1200
  16. data/lib/rigor/analysis/worker_session.rb +60 -11
  17. data/lib/rigor/bleeding_edge.rb +123 -0
  18. data/lib/rigor/cache/descriptor.rb +86 -8
  19. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  20. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  21. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  22. data/lib/rigor/cache/store.rb +46 -13
  23. data/lib/rigor/cli/annotate_command.rb +100 -15
  24. data/lib/rigor/cli/check_command.rb +708 -0
  25. data/lib/rigor/cli/ci_detector.rb +94 -0
  26. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  27. data/lib/rigor/cli/plugins_command.rb +2 -4
  28. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  29. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  30. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  31. data/lib/rigor/cli/trace_command.rb +143 -0
  32. data/lib/rigor/cli/trace_renderer.rb +310 -0
  33. data/lib/rigor/cli/triage_command.rb +6 -3
  34. data/lib/rigor/cli/triage_renderer.rb +15 -1
  35. data/lib/rigor/cli.rb +21 -612
  36. data/lib/rigor/configuration/severity_profile.rb +13 -1
  37. data/lib/rigor/configuration.rb +66 -7
  38. data/lib/rigor/environment/rbs_loader.rb +78 -68
  39. data/lib/rigor/environment.rb +1 -1
  40. data/lib/rigor/inference/acceptance.rb +10 -0
  41. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  42. data/lib/rigor/inference/budget_trace.rb +29 -2
  43. data/lib/rigor/inference/expression_typer.rb +1080 -105
  44. data/lib/rigor/inference/flow_tracer.rb +180 -0
  45. data/lib/rigor/inference/macro_block_self_type.rb +11 -12
  46. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  47. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  48. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  49. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  50. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  51. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  52. data/lib/rigor/inference/method_dispatcher.rb +187 -55
  53. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  54. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  55. data/lib/rigor/inference/mutation_widening.rb +142 -0
  56. data/lib/rigor/inference/narrowing.rb +330 -37
  57. data/lib/rigor/inference/scope_indexer.rb +770 -39
  58. data/lib/rigor/inference/statement_evaluator.rb +998 -68
  59. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  60. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  61. data/lib/rigor/plugin/base.rb +517 -120
  62. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  63. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  64. data/lib/rigor/plugin/macro.rb +2 -3
  65. data/lib/rigor/plugin/manifest.rb +4 -24
  66. data/lib/rigor/plugin/node_rule_walk.rb +192 -0
  67. data/lib/rigor/plugin/registry.rb +264 -35
  68. data/lib/rigor/plugin.rb +1 -0
  69. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  70. data/lib/rigor/scope/discovery_index.rb +60 -0
  71. data/lib/rigor/scope.rb +199 -204
  72. data/lib/rigor/sig_gen/generator.rb +8 -0
  73. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  74. data/lib/rigor/source/literals.rb +14 -0
  75. data/lib/rigor/triage/catalogue.rb +4 -19
  76. data/lib/rigor/triage.rb +69 -1
  77. data/lib/rigor/type/combinator.rb +34 -0
  78. data/lib/rigor/version.rb +1 -1
  79. data/lib/rigor.rb +0 -1
  80. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  81. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  82. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  83. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  84. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  85. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  86. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
  87. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  88. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
  89. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  90. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  91. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  93. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  94. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  96. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  97. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  98. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  99. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  100. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  102. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  103. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  104. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  105. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  106. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  107. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
  108. data/sig/rigor/analysis/fact_store.rbs +3 -0
  109. data/sig/rigor/environment.rbs +0 -2
  110. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  111. data/sig/rigor/inference.rbs +5 -0
  112. data/sig/rigor/plugin/base.rbs +6 -4
  113. data/sig/rigor/plugin/manifest.rbs +1 -2
  114. data/sig/rigor/scope.rbs +50 -29
  115. data/sig/rigor/source.rbs +1 -0
  116. data/sig/rigor/type.rbs +1 -0
  117. data/sig/rigor.rbs +1 -1
  118. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  119. data/skills/rigor-ci-setup/SKILL.md +319 -0
  120. data/skills/rigor-plugin-author/SKILL.md +6 -4
  121. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  122. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  123. metadata +21 -3
  124. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
  125. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optionparser"
5
+ require "prism"
6
+
7
+ require_relative "../configuration"
8
+ require_relative "../environment"
9
+ require_relative "../scope"
10
+ require_relative "../inference/flow_tracer"
11
+ require_relative "../inference/scope_indexer"
12
+ require_relative "command"
13
+ require_relative "trace_renderer"
14
+
15
+ module Rigor
16
+ class CLI
17
+ # Executes the `rigor trace` command: re-runs the inference engine
18
+ # over one file under {Rigor::Inference::FlowTracer} and replays the
19
+ # recorded event stream — scope binds, union formation, method
20
+ # dispatch, and (with `--verbose`) every expression enter/result —
21
+ # as a step-through terminal animation. A teaching probe: it shows
22
+ # HOW Rigor arrives at a type, where `rigor type-of` shows only the
23
+ # answer.
24
+ #
25
+ # The tracer is observational; this command never changes what
26
+ # `rigor check` would infer for the same file.
27
+ class TraceCommand < Command
28
+ USAGE = "Usage: rigor trace [options] FILE"
29
+
30
+ # Default frame kinds: the three teachable moments. :enter/:result
31
+ # add one frame per literal and drown the signal; `--verbose`
32
+ # opts into them.
33
+ DEFAULT_KINDS = %i[bind union dispatch].freeze
34
+ VERBOSE_KINDS = (%i[enter result] + DEFAULT_KINDS).freeze
35
+
36
+ # @return [Integer] CLI exit status.
37
+ def run
38
+ options = parse_options
39
+ file = @argv.first
40
+ if file.nil? || @argv.size != 1
41
+ @err.puts(USAGE)
42
+ return CLI::EXIT_USAGE
43
+ end
44
+ return 1 unless file_exists?(file)
45
+
46
+ execute(file: file, options: options)
47
+ end
48
+
49
+ private
50
+
51
+ def parse_options
52
+ options = { format: "text", delay: nil, verbose: false, line: nil, config: nil }
53
+ parser = OptionParser.new do |opts|
54
+ opts.banner = USAGE
55
+ opts.on("--format=FORMAT", "Output format: text (animation) or json (raw event stream)") do |value|
56
+ options[:format] = value
57
+ end
58
+ opts.on("--delay=SECONDS", Float,
59
+ "Autoplay with SECONDS between frames (default: step on key press)") do |value|
60
+ options[:delay] = value
61
+ end
62
+ opts.on("--line=N", Integer, "Only replay events whose source range starts on line N") do |value|
63
+ options[:line] = value
64
+ end
65
+ opts.on("--verbose", "Include every expression enter/result frame") { options[:verbose] = true }
66
+ opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
67
+ end
68
+ parser.parse!(@argv)
69
+ options
70
+ end
71
+
72
+ def execute(file:, options:)
73
+ configuration = Configuration.load(options.fetch(:config))
74
+ source = File.read(file, encoding: Encoding::UTF_8)
75
+ parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
76
+ return 1 if parse_errors?(parse_result, file)
77
+
78
+ events = record_events(parse_result.value, file, configuration)
79
+ frames = filter(events, options)
80
+
81
+ if options.fetch(:format) == "json"
82
+ @out.puts(JSON.pretty_generate(frames.map { |event| event_to_h(event) }))
83
+ return 0
84
+ end
85
+
86
+ if frames.empty?
87
+ @out.puts("trace: no events to replay (try --verbose)")
88
+ return 0
89
+ end
90
+
91
+ interactive = @out.respond_to?(:tty?) && @out.tty?
92
+ TraceRenderer.new(out: @out, source: source, file: file)
93
+ .play(frames, delay: options.fetch(:delay), interactive: interactive)
94
+ 0
95
+ end
96
+
97
+ # Mirrors the single-file path `rigor type-of` takes: a
98
+ # project-aware environment, an empty seed scope, one
99
+ # statement-level evaluation of the whole program — but recorded
100
+ # under the FlowTracer.
101
+ def record_events(root, file, configuration)
102
+ environment = Environment.for_project(
103
+ libraries: configuration.libraries,
104
+ signature_paths: configuration.signature_paths
105
+ )
106
+ scope = Scope.empty(environment: environment, source_path: file)
107
+ Inference::FlowTracer.record { scope.evaluate(root) }
108
+ end
109
+
110
+ def filter(events, options)
111
+ kinds = options.fetch(:verbose) ? VERBOSE_KINDS : DEFAULT_KINDS
112
+ frames = events.select { |event| kinds.include?(event.kind) }
113
+ line = options.fetch(:line)
114
+ frames = frames.select { |event| event.location && event.location[:start_line] == line } if line
115
+ frames
116
+ end
117
+
118
+ def event_to_h(event)
119
+ {
120
+ kind: event.kind,
121
+ depth: event.depth,
122
+ location: event.location,
123
+ stack: event.stack,
124
+ data: event.data
125
+ }
126
+ end
127
+
128
+ def file_exists?(file)
129
+ return true if File.file?(file)
130
+
131
+ @err.puts("trace: file not found: #{file}")
132
+ false
133
+ end
134
+
135
+ def parse_errors?(result, file)
136
+ return false if result.errors.empty?
137
+
138
+ result.errors.each { |error| @err.puts("#{file}:#{error.location.start_line}: #{error.message}") }
139
+ true
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "prism_colorizer"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # Replays a `Rigor::Inference::FlowTracer` event stream as a terminal
8
+ # animation for `rigor trace`. Each frame draws a box-drawn screen:
9
+ # the source panel (syntax-coloured, current node range underlined)
10
+ # side-by-side with the scope panel (locals accumulated from :bind
11
+ # events), and an event panel describing the current step and the
12
+ # expression stack.
13
+ #
14
+ # The frame is fitted to the measured terminal: the source panel
15
+ # scrolls vertically (a window centred on the line under evaluation)
16
+ # instead of overflowing the screen height, over-long rows are
17
+ # clipped with an ellipsis instead of wrapping, and the event panel
18
+ # keeps a minimum height of {EVENT_PANE_MIN} rows so the narration
19
+ # never collapses to a sliver.
20
+ #
21
+ # Pure ANSI + `io/console` (both stdlib) per ADR-0's zero-runtime-
22
+ # dependency policy. When the output is not a TTY the frames are
23
+ # printed sequentially without cursor control against a default
24
+ # 80×24 layout, which keeps the renderer deterministic under test.
25
+ class TraceRenderer
26
+ RESET = "\e[0m"
27
+ DIM = "\e[90m"
28
+ BOLD = "\e[1m"
29
+ HIGHLIGHT = "\e[7m" # reverse video for the frame's source range
30
+
31
+ EVENT_PANE_MIN = 2 # minimum event-panel rows
32
+ SCOPE_WIDTH_MIN = 19 # minimum scope-column width
33
+ BODY_HEIGHT_MIN = 3 # never shrink the source window below this
34
+ DEFAULT_SIZE = [24, 80].freeze
35
+
36
+ # @param out [IO]
37
+ # @param source [String] the traced file's source.
38
+ # @param file [String] display path.
39
+ def initialize(out:, source:, file:)
40
+ @out = out
41
+ @source = source
42
+ @file = file
43
+ @lines = source.lines.map(&:chomp)
44
+ end
45
+
46
+ # @param events [Array<Rigor::Inference::FlowTracer::Event>] the
47
+ # pre-filtered frame list (the command owns kind filtering).
48
+ # @param delay [Float, nil] seconds between frames (autoplay);
49
+ # nil = step on key press when interactive.
50
+ # @param interactive [Boolean] whether to clear/redraw and wait.
51
+ def play(events, delay: nil, interactive: false)
52
+ @rows, @cols = interactive ? terminal_size : DEFAULT_SIZE
53
+ @interactive = interactive
54
+ locals_per_frame = accumulate_locals(events)
55
+ events.each_with_index do |event, index|
56
+ @out.print("\e[H\e[2J") if interactive
57
+ render_frame(event, index: index, total: events.size, locals: locals_per_frame[index])
58
+ @out.puts unless interactive
59
+ next if index == events.size - 1
60
+
61
+ if delay
62
+ sleep(delay)
63
+ elsif interactive
64
+ break unless next_frame?
65
+ end
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ # `IO.console` reflects the controlling terminal even when stdout
72
+ # is, say, a StringIO under test — which is why the measured size
73
+ # is only used for interactive replays.
74
+ def terminal_size
75
+ require "io/console"
76
+ size = IO.console&.winsize
77
+ return size if size && size[0].positive? && size[1].positive?
78
+
79
+ DEFAULT_SIZE
80
+ rescue LoadError, SystemCallError
81
+ DEFAULT_SIZE
82
+ end
83
+
84
+ # Replays :bind events into a per-frame snapshot of the locals
85
+ # panel, so frame N shows exactly the bindings visible after the
86
+ # first N events.
87
+ def accumulate_locals(events)
88
+ locals = {}
89
+ events.map do |event|
90
+ locals[event.data[:name]] = event.data[:type] if event.kind == :bind
91
+ locals.dup
92
+ end
93
+ end
94
+
95
+ def render_frame(event, index:, total:, locals:)
96
+ inner = @cols - 3 # three vertical borders
97
+ lines = event_pane_lines(event, inner - 1)
98
+ body_height = body_height_for(lines.size)
99
+
100
+ source_rows = source_panel_rows(event)
101
+ left_width, right_width = column_widths(source_rows, inner)
102
+ source_rows = clip_rows(scroll_window(source_rows, body_height), left_width)
103
+ scope_rows = fit_rows(scope_panel_rows(locals), body_height, right_width)
104
+
105
+ @out.puts(top_border(left_width, right_width))
106
+ body_rows(source_rows, scope_rows, left_width, right_width)
107
+ @out.puts(divider(left_width, right_width, label: " step #{index + 1}/#{total} · #{event.kind} "))
108
+ lines.each { |line| @out.puts(boxed_line(line, left_width + right_width + 1)) }
109
+ @out.puts(bottom_border(left_width + right_width + 1))
110
+ end
111
+
112
+ # Screen budget: borders (3 rows) + event pane + the key-hint row
113
+ # when stepping interactively; whatever remains belongs to the
114
+ # source/scope body, floored at {BODY_HEIGHT_MIN}.
115
+ def body_height_for(event_rows)
116
+ chrome = 3 + event_rows + (@interactive ? 1 : 0)
117
+ [@rows - chrome, BODY_HEIGHT_MIN].max
118
+ end
119
+
120
+ # The event pane keeps at least {EVENT_PANE_MIN} rows (padding with
121
+ # blanks) so the bottom of the frame is visually stable across
122
+ # event kinds; over-long lines are clipped to the frame width.
123
+ def event_pane_lines(event, max_width)
124
+ lines = [describe_event(event), stack_line(event)].compact
125
+ lines << "" while lines.size < EVENT_PANE_MIN
126
+ lines.map { |line| clip(line, max_width) }
127
+ end
128
+
129
+ # Source rows win the width contest; the scope column keeps its
130
+ # minimum and absorbs whatever the source does not need.
131
+ def column_widths(source_rows, inner)
132
+ left_need = (source_rows.map { |raw, _| raw.length }.max || 0) + 1
133
+ left_width = left_need.clamp(24, [inner - SCOPE_WIDTH_MIN, 24].max)
134
+ [left_width, [inner - left_width, SCOPE_WIDTH_MIN].max]
135
+ end
136
+
137
+ # Vertical scroll: when the panel is taller than the window, keep
138
+ # a slice centred on the row under evaluation (the `▶` row, which
139
+ # the marker row directly follows).
140
+ def scroll_window(rows, height)
141
+ return rows if rows.size <= height
142
+
143
+ focus = rows.index { |raw, _| raw.start_with?("▶") } || 0
144
+ start = (focus - (height / 2)).clamp(0, rows.size - height)
145
+ rows[start, height]
146
+ end
147
+
148
+ def clip_rows(rows, width)
149
+ rows.map do |raw, painted|
150
+ next [raw, painted] if raw.length <= width
151
+
152
+ # Re-paint from the clipped raw text — clipping the painted
153
+ # string directly would cut ANSI escapes in half.
154
+ clipped = clip(raw, width)
155
+ [clipped, repaint_clipped(clipped)]
156
+ end
157
+ end
158
+
159
+ # A clipped row loses its highlight/marker alignment guarantees, so
160
+ # it is repainted with plain syntax colouring (gutter dimmed).
161
+ def repaint_clipped(clipped)
162
+ gutter = clipped[0, 6]
163
+ rest = clipped[6..] || ""
164
+ DIM + gutter + RESET + PrismColorizer.colorize(rest).chomp
165
+ end
166
+
167
+ def fit_rows(rows, height, width)
168
+ rows = rows[0, height - 1] + [" … (+#{rows.size - height + 1} more)"] if rows.size > height
169
+ rows.map { |row| clip(row, width) }
170
+ end
171
+
172
+ def clip(text, width)
173
+ return text if text.length <= width
174
+
175
+ "#{text[0, [width - 1, 0].max]}…"
176
+ end
177
+
178
+ # Each row is `[raw_text, painted_text]` so width math runs on the
179
+ # escape-free string. The row under the event's source range gets a
180
+ # `▔▔▔` marker row injected after it.
181
+ def source_panel_rows(event)
182
+ rows = []
183
+ location = event.location
184
+ @lines.each_with_index do |line, i|
185
+ number = i + 1
186
+ current = location && number == location[:start_line]
187
+ gutter = "#{current ? '▶' : ' '}#{number.to_s.rjust(3)} "
188
+ raw = gutter + line
189
+ painted = (current ? BOLD + gutter + RESET : DIM + gutter + RESET) + paint_line(line, location, number)
190
+ rows << [raw, painted]
191
+ rows << marker_row(gutter.length, line, location) if current
192
+ end
193
+ rows
194
+ end
195
+
196
+ def marker_row(gutter_width, line, location)
197
+ before, slice, = split_at_range(line, location)
198
+ indent = " " * (gutter_width + before.length)
199
+ width = [slice.length, 1].max
200
+ [indent + ("▔" * width), indent + BOLD + ("▔" * width) + RESET]
201
+ end
202
+
203
+ # Highlights the in-range slice with reverse video; everything else
204
+ # gets Prism syntax colouring. The highlight is applied on the raw
205
+ # slice (not the colorized string) so byte offsets stay honest.
206
+ def paint_line(line, location, number)
207
+ return PrismColorizer.colorize(line) unless location && number == location[:start_line]
208
+
209
+ before, slice, after = split_at_range(line, location)
210
+ return PrismColorizer.colorize(line) if slice.empty?
211
+
212
+ PrismColorizer.colorize(before).chomp +
213
+ HIGHLIGHT + slice + RESET +
214
+ PrismColorizer.colorize(after).chomp
215
+ end
216
+
217
+ # Prism columns are BYTE columns — split the line with byteslice
218
+ # so a multibyte character earlier on the line cannot shift (or
219
+ # overrun) the highlight range. Returns `[before, slice, after]`.
220
+ def split_at_range(line, location)
221
+ from = [location[:start_column], line.bytesize].min
222
+ to = location[:end_line] == location[:start_line] ? [location[:end_column], line.bytesize].min : line.bytesize
223
+ to = from if to < from
224
+ [line.byteslice(0, from), line.byteslice(from, to - from), line.byteslice(to, line.bytesize - to) || ""]
225
+ end
226
+
227
+ def scope_panel_rows(locals)
228
+ return [" (no locals yet)"] if locals.empty?
229
+
230
+ width = locals.keys.map(&:length).max
231
+ locals.map { |name, type| format(" %-#{width}s : %s", name, type) }
232
+ end
233
+
234
+ def describe_event(event)
235
+ data = event.data
236
+ case event.kind
237
+ when :bind then "bind #{data[:name]} ← #{data[:type]}"
238
+ when :union then "union #{data[:members].join(' | ')} → #{data[:type]}"
239
+ when :dispatch then describe_dispatch(data)
240
+ when :enter then "eval #{data[:node]}"
241
+ when :result then "result #{data[:node]} → #{data[:type]}"
242
+ else "#{event.kind} #{data.inspect}"
243
+ end
244
+ end
245
+
246
+ def describe_dispatch(data)
247
+ call = "#{data[:receiver]} ##{data[:method]}(#{data[:args].join(', ')})"
248
+ return "dispatch #{call} → #{data[:type]}" if data[:resolved]
249
+
250
+ "dispatch #{call} → no rule matched (fail-soft → Dynamic[top])"
251
+ end
252
+
253
+ def stack_line(event)
254
+ return nil if event.stack.empty?
255
+
256
+ "stack #{event.stack.join(' › ')}"
257
+ end
258
+
259
+ # -- box drawing ---------------------------------------------------
260
+
261
+ def top_border(left, right)
262
+ "┌#{pad_label("─ #{@file} ", left)}┬#{pad_label('─ scope ', right)}┐"
263
+ end
264
+
265
+ # Pads `label` with `─` to exactly `width` cells (truncating an
266
+ # over-long label so the frame never breaks).
267
+ def pad_label(label, width)
268
+ label = "#{label[0, width - 2]} " if label.length > width
269
+ label + ("─" * [width - label.length, 0].max)
270
+ end
271
+
272
+ def body_rows(source_rows, scope_rows, left, right)
273
+ [source_rows.size, scope_rows.size].max.times do |i|
274
+ raw, painted = source_rows[i] || ["", ""]
275
+ scope = scope_rows[i] || ""
276
+ @out.puts("│#{painted}#{' ' * [left - raw.length, 0].max}│#{scope}#{' ' * [right - scope.length, 0].max}│")
277
+ end
278
+ end
279
+
280
+ def divider(left, right, label:)
281
+ "├#{pad_label(label, left)}┴#{'─' * right}┤"
282
+ end
283
+
284
+ def boxed_line(text, width)
285
+ "│ #{text}#{' ' * [width - text.length - 1, 0].max}│"
286
+ end
287
+
288
+ def bottom_border(width)
289
+ "└#{'─' * width}┘"
290
+ end
291
+
292
+ # Single-keystroke stepping via stdlib io/console; falls back to
293
+ # line-buffered Enter when raw mode is unavailable (e.g. pipes that
294
+ # still claim to be TTYs). Returns false when the user quits.
295
+ def next_frame?
296
+ @out.print("#{DIM} [any key: next · q: quit]#{RESET}")
297
+ key = read_key
298
+ @out.print("\r\e[K")
299
+ key != "q"
300
+ end
301
+
302
+ def read_key
303
+ require "io/console"
304
+ $stdin.getch
305
+ rescue LoadError, Errno::ENOTTY, Errno::ENODEV
306
+ $stdin.gets&.strip
307
+ end
308
+ end
309
+ end
310
+ end
@@ -21,7 +21,7 @@ module Rigor
21
21
  # command, not a gate (`rigor check` remains the gate).
22
22
  class TriageCommand < Command
23
23
  USAGE = "Usage: rigor triage [options] [paths]"
24
- DEFAULT_SECTIONS = %i[distribution hotspots hints].freeze
24
+ DEFAULT_SECTIONS = %i[distribution selectors hotspots hints].freeze
25
25
 
26
26
  # @return [Integer] CLI exit status (always 0).
27
27
  def run
@@ -46,8 +46,11 @@ module Rigor
46
46
  opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
47
47
  opts.on("--top=N", Integer, "Hotspot-file count (default 10)") { |v| options[:top] = v }
48
48
  opts.on("--hints-only", "Print only the heuristic-hints section") { options[:sections] = %i[hints] }
49
- opts.on("--no-hints", "Print distribution + hotspots only") do
50
- options[:sections] = %i[distribution hotspots]
49
+ opts.on("--no-hints", "Print distribution + selectors + hotspots only") do
50
+ options[:sections] = %i[distribution selectors hotspots]
51
+ end
52
+ opts.on("--selectors-only", "Print only the class/method selectors section") do
53
+ options[:sections] = %i[selectors]
51
54
  end
52
55
  end.parse!(@argv)
53
56
  validate!(options)
@@ -10,10 +10,11 @@ module Rigor
10
10
  # triage` text report or as `--format json`.
11
11
  class TriageRenderer
12
12
  BAR_WIDTH = 24
13
+ SELECTOR_ROWS = 15 # text-output cap; `--format json` carries the full list
13
14
 
14
15
  def initialize(report, sections:)
15
16
  @report = report
16
- @sections = sections # subset of %i[distribution hotspots hints]
17
+ @sections = sections # subset of %i[distribution selectors hotspots hints]
17
18
  end
18
19
 
19
20
  def json
@@ -23,6 +24,7 @@ module Rigor
23
24
  def text
24
25
  blocks = []
25
26
  blocks << distribution_block if @sections.include?(:distribution)
27
+ blocks << selectors_block if @sections.include?(:selectors)
26
28
  blocks << hotspots_block if @sections.include?(:hotspots)
27
29
  blocks << hints_block if @sections.include?(:hints)
28
30
  "#{blocks.join("\n\n")}\n"
@@ -42,6 +44,18 @@ module Rigor
42
44
  lines.join("\n")
43
45
  end
44
46
 
47
+ def selectors_block
48
+ return "Selectors — by class / method\n (none)" if @report.selectors.empty?
49
+
50
+ lines = ["Selectors — by class / method (top #{SELECTOR_ROWS}; full list in --format json)"]
51
+ @report.selectors.first(SELECTOR_ROWS).each do |sel|
52
+ label = sel.receiver ? "#{sel.receiver}##{sel.method_name}" : sel.method_name
53
+ lines << format(" %<label>-44s %<count>5d %<files>3d file(s)",
54
+ label: label, count: sel.count, files: sel.files)
55
+ end
56
+ lines.join("\n")
57
+ end
58
+
45
59
  def hotspots_block
46
60
  return "Hotspot files\n (none)" if @report.hotspots.empty?
47
61