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.
- checksums.yaml +4 -4
- data/README.md +159 -222
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
- data/lib/rigor/analysis/check_rules.rb +275 -44
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
- data/lib/rigor/analysis/runner.rb +207 -1200
- data/lib/rigor/analysis/worker_session.rb +60 -11
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/incremental_snapshot.rb +10 -4
- data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +46 -13
- data/lib/rigor/cli/annotate_command.rb +100 -15
- data/lib/rigor/cli/check_command.rb +708 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/plugins_command.rb +2 -4
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli/triage_command.rb +6 -3
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli.rb +21 -612
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +66 -7
- data/lib/rigor/environment/rbs_loader.rb +78 -68
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -0
- data/lib/rigor/inference/body_fixpoint.rb +89 -0
- data/lib/rigor/inference/budget_trace.rb +29 -2
- data/lib/rigor/inference/expression_typer.rb +1080 -105
- data/lib/rigor/inference/flow_tracer.rb +180 -0
- data/lib/rigor/inference/macro_block_self_type.rb +11 -12
- data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
- data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
- data/lib/rigor/inference/method_dispatcher.rb +187 -55
- data/lib/rigor/inference/method_parameter_binder.rb +56 -2
- data/lib/rigor/inference/multi_target_binder.rb +46 -3
- data/lib/rigor/inference/mutation_widening.rb +142 -0
- data/lib/rigor/inference/narrowing.rb +330 -37
- data/lib/rigor/inference/scope_indexer.rb +770 -39
- data/lib/rigor/inference/statement_evaluator.rb +998 -68
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +517 -120
- data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
- data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
- data/lib/rigor/plugin/macro.rb +2 -3
- data/lib/rigor/plugin/manifest.rb +4 -24
- data/lib/rigor/plugin/node_rule_walk.rb +192 -0
- data/lib/rigor/plugin/registry.rb +264 -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 +60 -0
- data/lib/rigor/scope.rb +199 -204
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/combinator.rb +34 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +0 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
- 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-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- 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 +37 -31
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
- 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 +108 -36
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/inference.rbs +5 -0
- data/sig/rigor/plugin/base.rbs +6 -4
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +50 -29
- data/sig/rigor/source.rbs +1 -0
- data/sig/rigor/type.rbs +1 -0
- data/sig/rigor.rbs +1 -1
- data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
- data/skills/rigor-ci-setup/SKILL.md +319 -0
- data/skills/rigor-plugin-author/SKILL.md +6 -4
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
- metadata +21 -3
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
- 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
|
|