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