rigortype 0.1.17 → 0.1.18

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
  4. data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
  5. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +18 -1
  6. data/lib/rigor/analysis/check_rules.rb +34 -6
  7. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  8. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  9. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  10. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  11. data/lib/rigor/analysis/runner.rb +160 -1190
  12. data/lib/rigor/analysis/worker_session.rb +47 -8
  13. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  14. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  15. data/lib/rigor/cache/store.rb +46 -13
  16. data/lib/rigor/cli/check_command.rb +705 -0
  17. data/lib/rigor/cli/ci_detector.rb +94 -0
  18. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  19. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  20. data/lib/rigor/cli/trace_command.rb +143 -0
  21. data/lib/rigor/cli/trace_renderer.rb +310 -0
  22. data/lib/rigor/cli.rb +15 -614
  23. data/lib/rigor/configuration.rb +9 -6
  24. data/lib/rigor/environment/rbs_loader.rb +53 -68
  25. data/lib/rigor/environment.rb +1 -1
  26. data/lib/rigor/inference/acceptance.rb +10 -0
  27. data/lib/rigor/inference/expression_typer.rb +28 -62
  28. data/lib/rigor/inference/flow_tracer.rb +180 -0
  29. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  31. data/lib/rigor/inference/method_dispatcher.rb +115 -54
  32. data/lib/rigor/inference/narrowing.rb +60 -0
  33. data/lib/rigor/inference/scope_indexer.rb +75 -15
  34. data/lib/rigor/inference/statement_evaluator.rb +35 -52
  35. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  36. data/lib/rigor/plugin/base.rb +282 -41
  37. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  38. data/lib/rigor/plugin/registry.rb +263 -35
  39. data/lib/rigor/plugin.rb +1 -0
  40. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  41. data/lib/rigor/scope/discovery_index.rb +58 -0
  42. data/lib/rigor/scope.rb +67 -198
  43. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  44. data/lib/rigor/source/literals.rb +14 -0
  45. data/lib/rigor/type/combinator.rb +5 -0
  46. data/lib/rigor/version.rb +1 -1
  47. data/lib/rigor.rb +0 -1
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  49. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  50. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  53. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  54. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  55. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  56. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  57. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  58. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  59. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  60. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  61. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  62. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  63. data/sig/rigor/environment.rbs +0 -2
  64. data/sig/rigor/inference.rbs +5 -0
  65. data/sig/rigor/plugin/base.rbs +1 -2
  66. data/sig/rigor/scope.rbs +41 -29
  67. data/sig/rigor/source.rbs +1 -0
  68. data/skills/rigor-ci-setup/SKILL.md +319 -0
  69. metadata +15 -2
  70. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
@@ -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