rigortype 0.1.16 → 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 (180) 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/self_closedness_scanner.rb +100 -0
  6. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +226 -0
  7. data/lib/rigor/analysis/check_rules.rb +180 -73
  8. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  9. data/lib/rigor/analysis/diagnostic.rb +18 -0
  10. data/lib/rigor/analysis/incremental.rb +162 -0
  11. data/lib/rigor/analysis/incremental_session.rb +337 -0
  12. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  13. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  14. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  15. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  16. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  17. data/lib/rigor/analysis/runner.rb +477 -1110
  18. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  19. data/lib/rigor/analysis/worker_session.rb +47 -8
  20. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  21. data/lib/rigor/cache/descriptor.rb +50 -49
  22. data/lib/rigor/cache/incremental_snapshot.rb +153 -0
  23. data/lib/rigor/cache/rbs_cache_producer.rb +34 -0
  24. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  25. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  26. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  27. data/lib/rigor/cache/rbs_environment.rb +2 -8
  28. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  29. data/lib/rigor/cache/store.rb +145 -14
  30. data/lib/rigor/cli/annotate_command.rb +2 -7
  31. data/lib/rigor/cli/baseline_command.rb +2 -7
  32. data/lib/rigor/cli/check_command.rb +705 -0
  33. data/lib/rigor/cli/ci_detector.rb +94 -0
  34. data/lib/rigor/cli/command.rb +47 -0
  35. data/lib/rigor/cli/coverage_command.rb +3 -23
  36. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  37. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  38. data/lib/rigor/cli/diff_command.rb +3 -7
  39. data/lib/rigor/cli/explain_command.rb +2 -7
  40. data/lib/rigor/cli/lsp_command.rb +3 -7
  41. data/lib/rigor/cli/mcp_command.rb +3 -7
  42. data/lib/rigor/cli/options.rb +57 -0
  43. data/lib/rigor/cli/plugin_command.rb +3 -7
  44. data/lib/rigor/cli/plugins_command.rb +2 -7
  45. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  46. data/lib/rigor/cli/renderable.rb +26 -0
  47. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  48. data/lib/rigor/cli/skill_command.rb +3 -7
  49. data/lib/rigor/cli/trace_command.rb +143 -0
  50. data/lib/rigor/cli/trace_renderer.rb +310 -0
  51. data/lib/rigor/cli/triage_command.rb +2 -7
  52. data/lib/rigor/cli/type_of_command.rb +5 -38
  53. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  54. data/lib/rigor/cli/type_scan_command.rb +3 -23
  55. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  56. data/lib/rigor/cli.rb +15 -532
  57. data/lib/rigor/configuration/dependencies.rb +18 -1
  58. data/lib/rigor/configuration/severity_profile.rb +22 -3
  59. data/lib/rigor/configuration.rb +16 -3
  60. data/lib/rigor/environment/rbs_loader.rb +129 -71
  61. data/lib/rigor/environment.rb +1 -1
  62. data/lib/rigor/inference/acceptance.rb +10 -0
  63. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  64. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  65. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  69. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  70. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  71. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  72. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  73. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  74. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  75. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  76. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  77. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  78. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  79. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  80. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  81. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  82. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  83. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  84. data/lib/rigor/inference/expression_typer.rb +149 -63
  85. data/lib/rigor/inference/flow_tracer.rb +180 -0
  86. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  87. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  88. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  89. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  90. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  91. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  92. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  93. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  94. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  95. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  96. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  97. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  98. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  99. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
  100. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  101. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  102. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  103. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  104. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  105. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  106. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  107. data/lib/rigor/inference/method_dispatcher.rb +185 -84
  108. data/lib/rigor/inference/narrowing.rb +262 -5
  109. data/lib/rigor/inference/scope_indexer.rb +208 -21
  110. data/lib/rigor/inference/statement_evaluator.rb +110 -48
  111. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  112. data/lib/rigor/language_server/completion_provider.rb +4 -4
  113. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  114. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  115. data/lib/rigor/language_server/hover_provider.rb +4 -4
  116. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  117. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  118. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  119. data/lib/rigor/plugin/base.rb +302 -45
  120. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  121. data/lib/rigor/plugin/registry.rb +281 -15
  122. data/lib/rigor/plugin.rb +1 -0
  123. data/lib/rigor/rbs_extended/conformance_checker.rb +293 -0
  124. data/lib/rigor/rbs_extended.rb +39 -0
  125. data/lib/rigor/scope/discovery_index.rb +58 -0
  126. data/lib/rigor/scope.rb +150 -167
  127. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  128. data/lib/rigor/source/literals.rb +14 -0
  129. data/lib/rigor/type/acceptance_router.rb +19 -0
  130. data/lib/rigor/type/accepts_result.rb +3 -10
  131. data/lib/rigor/type/app.rb +3 -7
  132. data/lib/rigor/type/bot.rb +2 -3
  133. data/lib/rigor/type/bound_method.rb +5 -12
  134. data/lib/rigor/type/combinator.rb +22 -0
  135. data/lib/rigor/type/constant.rb +2 -3
  136. data/lib/rigor/type/data_class.rb +80 -0
  137. data/lib/rigor/type/data_instance.rb +100 -0
  138. data/lib/rigor/type/difference.rb +5 -10
  139. data/lib/rigor/type/dynamic.rb +5 -10
  140. data/lib/rigor/type/hash_shape.rb +5 -15
  141. data/lib/rigor/type/integer_range.rb +5 -10
  142. data/lib/rigor/type/intersection.rb +5 -10
  143. data/lib/rigor/type/nominal.rb +5 -10
  144. data/lib/rigor/type/refined.rb +5 -10
  145. data/lib/rigor/type/singleton.rb +5 -10
  146. data/lib/rigor/type/top.rb +2 -3
  147. data/lib/rigor/type/tuple.rb +5 -10
  148. data/lib/rigor/type/union.rb +5 -10
  149. data/lib/rigor/type.rb +2 -0
  150. data/lib/rigor/value_semantics.rb +77 -0
  151. data/lib/rigor/version.rb +1 -1
  152. data/lib/rigor.rb +1 -1
  153. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  154. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  156. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  157. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  158. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  159. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  160. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  161. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  163. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  164. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  166. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  167. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  168. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  169. data/sig/rigor/cache.rbs +19 -0
  170. data/sig/rigor/environment.rbs +0 -2
  171. data/sig/rigor/inference.rbs +27 -0
  172. data/sig/rigor/plugin/base.rbs +1 -2
  173. data/sig/rigor/rbs_extended.rbs +2 -0
  174. data/sig/rigor/scope.rbs +42 -25
  175. data/sig/rigor/source.rbs +1 -0
  176. data/sig/rigor/type.rbs +58 -1
  177. data/sig/rigor.rbs +6 -1
  178. data/skills/rigor-ci-setup/SKILL.md +319 -0
  179. metadata +36 -2
  180. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -79
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../analysis/buffer_binding"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # Shared option plumbing for the subcommands.
8
+ #
9
+ # Today it owns the editor-mode surface that `rigor check` and
10
+ # `rigor type-of` both expose: the `--tmp-file` / `--instead-of` flag
11
+ # pair and the buffer-binding resolution that validates it. That
12
+ # resolution used to be copied verbatim into both `Rigor::CLI` and
13
+ # `TypeOfCommand`, so a fix to one (the paired-flag check, the
14
+ # readability check, the error wording) could silently miss the
15
+ # other. Centralising it keeps the two editor-mode entry points in
16
+ # lockstep.
17
+ module Options
18
+ module_function
19
+
20
+ # Defines the `--tmp-file` / `--instead-of` editor-mode flag pair
21
+ # on `parser`, writing into `options`.
22
+ def add_editor_mode(parser, options)
23
+ parser.on("--tmp-file=PATH",
24
+ "Editor mode: read source bytes from PATH instead of --instead-of (paired)") do |value|
25
+ options[:tmp_file] = value
26
+ end
27
+ parser.on("--instead-of=PATH",
28
+ "Editor mode: the logical project path the buffer represents (paired with --tmp-file)") do |value|
29
+ options[:instead_of] = value
30
+ end
31
+ end
32
+
33
+ # Resolves the editor-mode buffer binding from parsed `options`:
34
+ # returns nil when neither editor-mode flag is set, a
35
+ # `Analysis::BufferBinding` when the pair is valid, or
36
+ # `:usage_error` (after writing the reason to `err`) when the flags
37
+ # are unpaired or the temp file is unreadable.
38
+ def resolve_buffer_binding(options, err:)
39
+ tmp = options[:tmp_file]
40
+ instead = options[:instead_of]
41
+ return nil if tmp.nil? && instead.nil?
42
+
43
+ if tmp.nil? || instead.nil?
44
+ err.puts("--tmp-file and --instead-of must appear together")
45
+ return :usage_error
46
+ end
47
+
48
+ unless File.file?(tmp)
49
+ err.puts("--tmp-file #{tmp.inspect}: no such file or not readable")
50
+ return :usage_error
51
+ end
52
+
53
+ Analysis::BufferBinding.new(logical_path: instead, physical_path: tmp)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "command"
4
+
3
5
  module Rigor
4
6
  class CLI
5
7
  # `rigor plugin` (singular) — discover and read the plugin source
@@ -44,7 +46,7 @@ module Rigor
44
46
  # will not resolve — read them from the same environment that ran
45
47
  # the command (`rigor plugin print` inlines the body for exactly
46
48
  # this case: it works with no file-reading tool at all).
47
- class PluginCommand
49
+ class PluginCommand < Command
48
50
  USAGE = <<~USAGE
49
51
  Usage: rigor plugin <subcommand> [args]
50
52
 
@@ -72,12 +74,6 @@ module Rigor
72
74
  PLUGINS_ROOT = File.join(GEM_ROOT, "plugins")
73
75
  EXAMPLES_ROOT = File.join(GEM_ROOT, "examples")
74
76
 
75
- def initialize(argv:, out: $stdout, err: $stderr)
76
- @argv = argv
77
- @out = out
78
- @err = err
79
- end
80
-
81
77
  # @return [Integer] CLI exit status.
82
78
  def run
83
79
  subcommand = @argv.shift || "list"
@@ -9,6 +9,7 @@ require_relative "../plugin/services"
9
9
  require_relative "../reflection"
10
10
  require_relative "../type/combinator"
11
11
  require_relative "plugins_renderer"
12
+ require_relative "command"
12
13
 
13
14
  module Rigor
14
15
  class CLI
@@ -60,15 +61,9 @@ module Rigor
60
61
  # the RBS environment without conflict (requires constructing
61
62
  # the Environment, which is heavier than the loader-only
62
63
  # pass this slice does).
63
- class PluginsCommand # rubocop:disable Metrics/ClassLength
64
+ class PluginsCommand < Command # rubocop:disable Metrics/ClassLength
64
65
  USAGE = "Usage: rigor plugins [options]"
65
66
 
66
- def initialize(argv:, out: $stdout, err: $stderr)
67
- @argv = argv
68
- @out = out
69
- @err = err
70
- end
71
-
72
67
  # @return [Integer] CLI exit status.
73
68
  def run
74
69
  options = parse_options
@@ -41,12 +41,19 @@ module Rigor
41
41
  # @return [String] the source with ANSI colour escapes, or
42
42
  # the input unchanged when lexing surfaces an error.
43
43
  def colorize(source)
44
+ # Sources read under a POSIX locale arrive tagged US-ASCII even
45
+ # when they carry UTF-8 bytes; retag so the token regexes below
46
+ # do not raise on multibyte comments.
47
+ source = source.dup.force_encoding(Encoding::UTF_8) unless source.encoding == Encoding::UTF_8
44
48
  result = Prism.lex(source)
45
49
  return source unless result.errors.empty?
46
50
 
47
51
  render(source, result.value)
48
52
  end
49
53
 
54
+ # Prism token offsets are BYTE offsets — slice with byteslice, or
55
+ # any multibyte character earlier in the source shifts every
56
+ # subsequent token boundary.
50
57
  def render(source, lexed)
51
58
  out = +""
52
59
  offset = 0
@@ -54,15 +61,15 @@ module Rigor
54
61
  lexed.each do |entry|
55
62
  token = entry.first
56
63
  location = token.location
57
- out << source[offset...location.start_offset]
64
+ out << (source.byteslice(offset, [location.start_offset - offset, 0].max) || "")
58
65
  break if token.type == :EOF
59
66
 
60
- text = source[location.start_offset...location.end_offset]
67
+ text = source.byteslice(location.start_offset, location.end_offset - location.start_offset) || ""
61
68
  out << paint(text, effective_category(token.type, previous_type))
62
69
  offset = location.end_offset
63
70
  previous_type = token.type
64
71
  end
65
- out << (source[offset..] || "")
72
+ out << (source.byteslice(offset, source.bytesize - offset) || "")
66
73
  out
67
74
  end
68
75
 
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optionparser"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # Output-format dispatch shared by the `--format text|json` renderers.
8
+ #
9
+ # Each renderer included this and then implemented `render_text` /
10
+ # `render_json`; the `render(data, format:)` entry point — route by
11
+ # the format string, raise one consistent `OptionParser::InvalidArgument`
12
+ # on anything else — was copied verbatim into every one. Centralising
13
+ # it keeps the unsupported-format wording and the text/json contract
14
+ # in a single place as new renderers and formats are added.
15
+ module Renderable
16
+ def render(data, format:)
17
+ case format
18
+ when "text" then render_text(data)
19
+ when "json" then render_json(data)
20
+ else
21
+ raise OptionParser::InvalidArgument, "unsupported format: #{format}"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -4,6 +4,7 @@ require "optionparser"
4
4
 
5
5
  require_relative "../configuration"
6
6
  require_relative "../sig_gen"
7
+ require_relative "command"
7
8
 
8
9
  module Rigor
9
10
  class CLI
@@ -34,19 +35,13 @@ module Rigor
34
35
  # `--params=observed-strict` stays reserved-but-inert until
35
36
  # the capability-role catalog ships (rejected with a usage
36
37
  # error so the surface stays stable).
37
- class SigGenCommand
38
+ class SigGenCommand < Command
38
39
  USAGE = "Usage: rigor sig-gen [options] [paths]"
39
40
 
40
41
  VALID_MODES = %w[print diff write].freeze
41
42
  VALID_PARAM_POLICIES = %w[untyped observed observed-strict].freeze
42
43
  VALID_FORMATS = %w[text json].freeze
43
44
 
44
- def initialize(argv:, out:, err:)
45
- @argv = argv
46
- @out = out
47
- @err = err
48
- end
49
-
50
45
  # @return [Integer] CLI exit status.
51
46
  def run
52
47
  options = parse_options
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "command"
4
+
3
5
  require "optparse"
4
6
 
5
7
  module Rigor
@@ -29,7 +31,7 @@ module Rigor
29
31
  # as input to a Read tool.
30
32
  #
31
33
  # `rigor skill` with no subcommand is an alias for `list`.
32
- class SkillCommand
34
+ class SkillCommand < Command
33
35
  USAGE = <<~USAGE
34
36
  Usage: rigor skill <subcommand> [args]
35
37
 
@@ -48,12 +50,6 @@ module Rigor
48
50
  # `lib/rigor/cli/skill_command.rb` that is three directories up.
49
51
  SKILLS_ROOT = File.expand_path("../../../skills", __dir__)
50
52
 
51
- def initialize(argv:, out: $stdout, err: $stderr)
52
- @argv = argv
53
- @out = out
54
- @err = err
55
- end
56
-
57
53
  # @return [Integer] CLI exit status.
58
54
  def run
59
55
  subcommand = @argv.shift || "list"
@@ -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