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
data/lib/rigor/cli.rb CHANGED
@@ -9,13 +9,15 @@ require_relative "configuration"
9
9
  require_relative "version"
10
10
  require_relative "analysis/diagnostic"
11
11
  require_relative "analysis/result"
12
+ require_relative "cli/options"
13
+ require_relative "cli/diagnostic_formats"
14
+ require_relative "cli/ci_detector"
12
15
 
13
16
  module Rigor
14
17
  # The CLI class is a dispatcher: each `run_*` method delegates to a
15
18
  # command-specific class once the command grows beyond a few lines (see
16
- # {CLI::TypeOfCommand}). The class-length budget is intentionally relaxed
17
- # here so dispatch wiring can live alongside still-inlined commands.
18
- class CLI # rubocop:disable Metrics/ClassLength
19
+ # {CLI::TypeOfCommand} and {CLI::CheckCommand}).
20
+ class CLI
19
21
  EXIT_USAGE = 64
20
22
 
21
23
  HANDLERS = {
@@ -23,6 +25,7 @@ module Rigor
23
25
  "init" => :run_init,
24
26
  "annotate" => :run_annotate,
25
27
  "type-of" => :run_type_of,
28
+ "trace" => :run_trace,
26
29
  "type-scan" => :run_type_scan,
27
30
  "explain" => :run_explain,
28
31
  "diff" => :run_diff,
@@ -78,508 +81,9 @@ module Rigor
78
81
  end
79
82
 
80
83
  def run_check
81
- load_check_dependencies
82
- options = parse_check_options
83
- buffer = resolve_buffer_binding(options)
84
- return EXIT_USAGE if buffer == :usage_error
85
-
86
- configuration = load_check_configuration(options)
87
- cache_root = configuration.cache_path
88
- handle_clear_cache(cache_root) if options.fetch(:clear_cache)
89
-
90
- runner = build_check_runner(
91
- configuration: configuration, options: options,
92
- buffer: buffer, cache_root: cache_root
93
- )
94
- raw_result = runner.run(@argv.empty? ? configuration.paths : @argv)
95
- result = apply_baseline_filter(raw_result, configuration, options)
96
-
97
- write_result(result, options.fetch(:format))
98
- write_run_stats(result.stats) if result.stats
99
- write_trace_appendices
100
- write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
101
-
102
- exit_code = result.success? ? 0 : 1
103
- exit_code = 1 if baseline_strict_violation?(raw_result.diagnostics, configuration, options)
104
- exit_code
105
- end
106
-
107
- # ADR-22 slice 5 — the `--baseline-strict` CI gate. When the
108
- # flag is set, ANY baseline drift fails the run — not only
109
- # excess drift (a bucket over threshold, which already fails
110
- # via the surfaced diagnostics) but also DEFICIT drift
111
- # (`actual < count`: the baseline has grown looser than the
112
- # code and should be regenerated). A no-op, with a stderr
113
- # note, when no baseline is active — the flag never
114
- # implicitly loads a baseline the config did not name (WD2).
115
- def baseline_strict_violation?(raw_diagnostics, configuration, options)
116
- return false unless options.fetch(:baseline_strict)
117
-
118
- path = resolve_baseline_path(configuration, options)
119
- if path.nil?
120
- @err.puts("rigor: --baseline-strict given but no baseline is active; nothing to gate.")
121
- return false
122
- end
123
-
124
- baseline = Analysis::Baseline.load(path, project_root: Dir.pwd)
125
- return false if baseline.nil? || baseline.empty?
126
-
127
- drifted = baseline.audit(raw_diagnostics).reject { |row| row.status == :within }
128
- return false if drifted.empty?
129
-
130
- report_strict_drift(drifted, path)
131
- true
132
- rescue Analysis::Baseline::LoadError => e
133
- @err.puts("rigor: baseline load failed: #{e.message} (--baseline-strict gate skipped)")
134
- false
135
- end
136
-
137
- def report_strict_drift(rows, path)
138
- @err.puts("rigor: --baseline-strict — #{rows.size} bucket(s) drifted from #{path}:")
139
- rows.sort_by { |r| [r.bucket.file, r.bucket.rule] }.each do |row|
140
- delta = row.delta.positive? ? "+#{row.delta}" : row.delta.to_s
141
- @err.puts(" #{row.bucket.file} [#{row.bucket.rule}] " \
142
- "#{row.bucket.count} → #{row.actual_count} (Δ#{delta}, #{row.status})")
143
- end
144
- @err.puts("rigor: run `rigor baseline regenerate` to refresh the baseline.")
145
- end
146
-
147
- # ADR-22 — apply the baseline filter as the LAST step of
148
- # the diagnostic pipeline (after `# rigor:disable`,
149
- # `severity_profile`, etc. — WD6). Resolution order
150
- # follows WD2 (b):
151
- #
152
- # 1. --no-baseline on the CLI → no baseline.
153
- # 2. --baseline=PATH on the CLI → load that path.
154
- # 3. .rigor.yml's `baseline: <path>` → load that path.
155
- # 4. otherwise → no baseline.
156
- #
157
- # When the path resolves and loads successfully, the filter
158
- # replaces `result.diagnostics` with the surfaced set and
159
- # writes a one-line summary to stderr (WD7) when any
160
- # diagnostics were silenced. Load failures emit a warning
161
- # to stderr and fall through to "no baseline" (graceful
162
- # degradation).
163
- def apply_baseline_filter(result, configuration, options)
164
- path = resolve_baseline_path(configuration, options)
165
- return result if path.nil?
166
-
167
- baseline = Analysis::Baseline.load(path, project_root: Dir.pwd)
168
- return result if baseline.nil?
169
-
170
- surfaced, silenced_count = baseline.filter(result.diagnostics)
171
- report_baseline_summary(silenced_count, path) if silenced_count.positive?
172
- Analysis::Result.new(diagnostics: surfaced, stats: result.stats)
173
- rescue Analysis::Baseline::LoadError => e
174
- @err.puts("rigor: baseline load failed: #{e.message} (continuing without baseline)")
175
- result
176
- end
177
-
178
- # WD2 (b) — resolve effective baseline path.
179
- def resolve_baseline_path(configuration, options)
180
- cli_value = options.fetch(:baseline)
181
- case cli_value
182
- when false then nil # --no-baseline
183
- when :unset then configuration.baseline_path # fall through to config
184
- else cli_value # --baseline=PATH
185
- end
186
- end
187
-
188
- def report_baseline_summary(silenced_count, baseline_path)
189
- @err.puts("rigor: #{silenced_count} diagnostic(s) silenced by baseline #{baseline_path}")
190
- end
191
-
192
- def build_check_runner(configuration:, options:, buffer:, cache_root:)
193
- cache_store = options.fetch(:no_cache) ? nil : Cache::Store.new(root: cache_root)
194
- Analysis::Runner.new(
195
- configuration: configuration,
196
- explain: options.fetch(:explain),
197
- cache_store: cache_store,
198
- collect_stats: options.fetch(:stats),
199
- workers: resolve_workers(options, configuration),
200
- buffer: buffer
201
- )
202
- end
203
-
204
- # Editor-mode CLI envelope. The `--tmp-file=PATH` /
205
- # `--instead-of=PATH` pair binds an in-flight buffer file to
206
- # the logical project path it represents (see
207
- # `docs/design/20260516-editor-mode.md`). Both flags must
208
- # appear together; either alone is a usage error. The
209
- # physical file must be readable; missing-file is a usage
210
- # error too so editors get one consistent failure shape.
211
- #
212
- # Returns:
213
- # - `nil` when neither flag was supplied (legacy path).
214
- # - `Rigor::Analysis::BufferBinding` when the pair is valid.
215
- # - `:usage_error` after writing one diagnostic to stderr;
216
- # the caller MUST translate this to `EXIT_USAGE`.
217
- def resolve_buffer_binding(options)
218
- tmp = options[:tmp_file]
219
- instead = options[:instead_of]
220
- return nil if tmp.nil? && instead.nil?
221
-
222
- if tmp.nil? || instead.nil?
223
- @err.puts("--tmp-file and --instead-of must appear together")
224
- return :usage_error
225
- end
226
-
227
- unless File.file?(tmp)
228
- @err.puts("--tmp-file #{tmp.inspect}: no such file or not readable")
229
- return :usage_error
230
- end
231
-
232
- Analysis::BufferBinding.new(logical_path: instead, physical_path: tmp)
233
- end
234
-
235
- # ADR-15 Phase 4c — resolves the worker count by
236
- # precedence: CLI `--workers=N` (most explicit) > env
237
- # `RIGOR_RACTOR_WORKERS` > config `.rigor.yml`
238
- # `parallel.workers:` > 0 (sequential default). Returns
239
- # an Integer; non-numeric values raise so typos fail
240
- # loudly. CLI / env may pass a negative value — clamped
241
- # to 0 (sequential) so a stray `-1` doesn't crash the
242
- # pool spawn loop.
243
- def resolve_workers(options, configuration)
244
- cli_value = options[:workers]
245
- return [Integer(cli_value), 0].max if cli_value
246
-
247
- env_value = ENV.fetch("RIGOR_RACTOR_WORKERS", nil)
248
- return [Integer(env_value), 0].max if env_value && !env_value.empty?
249
-
250
- configuration.parallel_workers
251
- end
252
-
253
- def parse_check_options # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
254
- options = {
255
- # `nil` triggers `Configuration.discover` (`.rigor.yml` then
256
- # `.rigor.dist.yml`); an explicit `--config=PATH` overrides.
257
- config: nil,
258
- format: "text",
259
- explain: false,
260
- cache_stats: false,
261
- clear_cache: false,
262
- no_cache: false,
263
- # Run-stats summary (target files, RBS class universe
264
- # breakdown, wall time, peak RSS) is on by default
265
- # because collection is ~free (single syscall for RSS,
266
- # one walk of `class_decl_paths` for the breakdown).
267
- # `--no-stats` suppresses it for callers that want a
268
- # diagnostic-only output stream.
269
- stats: true,
270
- # ADR-15 Phase 4c — when nil, falls back to
271
- # `RIGOR_RACTOR_WORKERS` then `.rigor.yml`
272
- # `parallel.workers:` then 0 (sequential). See
273
- # `resolve_workers` for the precedence chain.
274
- workers: nil,
275
- # Editor mode (`docs/design/20260516-editor-mode.md`).
276
- # Both must appear together; the runner uses the pair
277
- # to bind an in-flight buffer file to its logical path.
278
- tmp_file: nil,
279
- instead_of: nil,
280
- # ADR-22 — baseline filter. `:unset` means "fall through
281
- # to `.rigor.yml`'s `baseline:` key"; a String overrides
282
- # the config; `false` (from `--no-baseline`) suppresses
283
- # any baseline that the config might name.
284
- baseline: :unset,
285
- # ADR-22 slice 5 — `--baseline-strict` CI gate: fail the
286
- # run on any baseline drift, in either direction.
287
- baseline_strict: false,
288
- # ADR-32 WD10 carry-over — `--treat-all-as-inline-rbs`
289
- # forces the `rigor-rbs-inline` plugin into the loaded
290
- # plugin set with `require_magic_comment: false` so a
291
- # single ad-hoc `rigor check` invocation treats every
292
- # analysed file as inline-RBS without the user editing
293
- # `.rigor.yml`. Intended for single-file / ad-hoc CI use;
294
- # ordinary projects should configure the plugin in
295
- # `.rigor.yml`.
296
- treat_all_as_inline_rbs: false
297
- }
298
- parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
299
- opts.banner = "Usage: rigor check [options] [paths]"
300
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
301
- opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
302
- opts.on("--explain", "Surface fail-soft fallback events as :info diagnostics") { options[:explain] = true }
303
- opts.on("--cache-stats", "Print on-disk cache inventory at end of run") { options[:cache_stats] = true }
304
- opts.on("--clear-cache", "Remove the .rigor/cache directory before running") { options[:clear_cache] = true }
305
- opts.on("--no-cache", "Disable the persistent cache for this run") { options[:no_cache] = true }
306
- opts.on("--[no-]stats",
307
- "Print run summary (files, classes, memory, wall time) to stderr (default: on)") do |value|
308
- options[:stats] = value
309
- end
310
- opts.on("--workers=N", Integer,
311
- "Dispatch per-file analysis across N Ractor workers (default: 0; sequential)") do |value|
312
- options[:workers] = value
313
- end
314
- opts.on("--tmp-file=PATH",
315
- "Editor mode: read source bytes from PATH instead of --instead-of (paired)") do |value|
316
- options[:tmp_file] = value
317
- end
318
- opts.on("--instead-of=PATH",
319
- "Editor mode: the logical project path the buffer represents (paired with --tmp-file)") do |value|
320
- options[:instead_of] = value
321
- end
322
- opts.on("--baseline=PATH",
323
- "ADR-22: load baseline from PATH (overrides .rigor.yml `baseline:`)") do |value|
324
- options[:baseline] = value
325
- end
326
- opts.on("--no-baseline",
327
- "ADR-22: ignore any configured baseline for this run") do
328
- options[:baseline] = false
329
- end
330
- opts.on("--baseline-strict",
331
- "ADR-22: fail the run on any baseline drift (CI gate)") do
332
- options[:baseline_strict] = true
333
- end
334
- opts.on("--treat-all-as-inline-rbs",
335
- "ADR-32: force-load rigor-rbs-inline with require_magic_comment: false") do
336
- options[:treat_all_as_inline_rbs] = true
337
- end
338
- end
339
- parser.parse!(@argv)
340
- options
341
- end
342
-
343
- # ADR-32 WD10 carry-over — wraps `Configuration.load` so the
344
- # CLI's `--treat-all-as-inline-rbs` flag can inject a
345
- # `rigor-rbs-inline` plugin entry with
346
- # `require_magic_comment: false` into the loaded plugin
347
- # set. Re-runs the include-aware YAML load and applies the
348
- # injection before `Configuration.new` so the new entry
349
- # follows the normal coercion path. A pre-existing
350
- # `rigor-rbs-inline` entry (by gem name or `id: rbs-inline`)
351
- # is removed first so the synthesised entry's
352
- # `require_magic_comment: false` wins unconditionally.
353
- def load_check_configuration(options)
354
- return Configuration.load(options.fetch(:config)) unless options.fetch(:treat_all_as_inline_rbs)
355
-
356
- path = options.fetch(:config) || Configuration.discover
357
- data = path && File.exist?(path) ? Configuration.load_with_includes(path) : {}
358
- data = data.dup
359
- data["plugins"] = inject_treat_all_as_inline_rbs(Array(data["plugins"]))
360
- Configuration.new(Configuration::DEFAULTS.merge(data))
361
- end
362
-
363
- def inject_treat_all_as_inline_rbs(entries)
364
- filtered = entries.reject { |entry| rigor_rbs_inline_entry?(entry) }
365
- filtered + [{
366
- "gem" => "rigor-rbs-inline",
367
- "id" => "rbs-inline",
368
- "config" => { "require_magic_comment" => false }
369
- }]
370
- end
371
-
372
- def rigor_rbs_inline_entry?(entry)
373
- case entry
374
- when String
375
- entry == "rigor-rbs-inline"
376
- when Hash
377
- string_keyed = entry.to_h { |k, v| [k.to_s, v] }
378
- string_keyed["gem"] == "rigor-rbs-inline" || string_keyed["id"] == "rbs-inline"
379
- else
380
- false
381
- end
382
- end
383
-
384
- def handle_clear_cache(cache_root)
385
- if File.directory?(cache_root)
386
- FileUtils.rm_rf(cache_root)
387
- @out.puts("Cleared cache: #{cache_root}")
388
- else
389
- @out.puts("Cache already empty: #{cache_root}")
390
- end
391
- end
392
-
393
- # Emits the {Analysis::RunStats} summary to STDERR so it
394
- # doesn't interleave with the diagnostic stream (text or
395
- # JSON) on STDOUT. JSON consumers can pipe stdout cleanly;
396
- # interactive users still see the summary on their tty.
397
- def write_run_stats(stats)
398
- @err.puts("")
399
- stats.format(@err)
400
- end
401
-
402
- # Opt-in developer diagnostics printed after the run: the
403
- # inference-cutoff trace (RIGOR_BUDGET_TRACE) and the heap-attribution
404
- # profile (RIGOR_HEAP_PROFILE). Each gates itself, so this is a no-op
405
- # on a normal run.
406
- def write_trace_appendices
407
- write_budget_trace
408
- write_heap_profile
409
- end
84
+ require_relative "cli/check_command"
410
85
 
411
- # Dumps the opt-in inference-cutoff counters (RIGOR_BUDGET_TRACE).
412
- # These are the hard-coded "budget" guards that silently degrade
413
- # to `Dynamic[top]` / a fallback bound — counting them shows where
414
- # inference actually stopped. Process-global counters: meaningful
415
- # only on a single-process run (`--workers 0`), since they do not
416
- # cross fork boundaries.
417
- def write_budget_trace
418
- return unless Inference::BudgetTrace.enabled?
419
-
420
- counts = Inference::BudgetTrace.snapshot
421
- @err.puts("")
422
- @err.puts("Inference cutoffs (RIGOR_BUDGET_TRACE; --workers 0 for an exact count)")
423
- @err.puts(" recursion-guard hits: #{counts[Inference::BudgetTrace::RECURSION_GUARD]}")
424
- @err.puts(" ancestor-walk-limit hits: #{counts[Inference::BudgetTrace::ANCESTOR_WALK_LIMIT]}")
425
- @err.puts(" hkt-fuel-exhausted hits: #{counts[Inference::BudgetTrace::HKT_FUEL_EXHAUSTED]}")
426
- write_budget_distributions
427
- end
428
-
429
- # Dumps the read-only size distributions (ADR-41 Slice 2a). These
430
- # observe how large unions actually get, with no cap enforced — the
431
- # data the `union_size` budget default should be chosen from. The
432
- # `over` thresholds bracket the TypeProf prior (10) and Rigor's spec
433
- # default (24).
434
- def write_budget_distributions
435
- summary = Inference::BudgetTrace.summarize(Inference::BudgetTrace::UNION_ARITY, over: [10, 24, 40])
436
- pct = summary[:percentiles]
437
- @err.puts(" union arity: n=#{summary[:count]} max=#{summary[:max]} " \
438
- "p50=#{pct[:p50]} p90=#{pct[:p90]} p99=#{pct[:p99]}")
439
- over = summary[:over]
440
- @err.puts(" unions ≥10: #{over[10]} ≥24: #{over[24]} ≥40: #{over[40]}")
441
- end
442
-
443
- # Dumps a live-heap class breakdown (RIGOR_HEAP_PROFILE) — retained
444
- # objects by class after a forced GC, ranked by total memsize. The
445
- # tool for attributing where the analyzer's resident memory goes
446
- # (ADR-41 Slice 2b): it answers whether the heap is type carriers,
447
- # RBS objects, Prism nodes, or fact-store Hashes/Strings. Walking the
448
- # whole heap is slow — a dev probe, not a normal diagnostic. Run
449
- # single-process (`--workers 0`) so the parent heap is the analysis
450
- # heap; the gem is required lazily so a normal run never loads it.
451
- def write_heap_profile
452
- return if ENV["RIGOR_HEAP_PROFILE"].to_s.empty?
453
-
454
- by_class, total = tally_live_heap
455
- @err.puts("")
456
- @err.puts("Heap profile (RIGOR_HEAP_PROFILE; live objects after GC, by class)")
457
- @err.puts(" total tracked: #{heap_mb(total)} across #{by_class.size} classes")
458
- by_class.sort_by { |_, (_, bytes)| -bytes }.first(30).each do |name, (count, bytes)|
459
- @err.puts(" #{heap_mb(bytes).rjust(10)} #{count.to_s.rjust(9)} obj #{name}")
460
- end
461
- write_string_allocation_sites
462
- end
463
-
464
- # Loads the analysis-path dependencies lazily (so non-check commands
465
- # stay light) and starts heap-allocation tracing if requested, before
466
- # any analysis object is allocated.
467
- def load_check_dependencies
468
- require_relative "analysis/runner"
469
- require_relative "analysis/buffer_binding"
470
- require_relative "analysis/baseline"
471
- require_relative "cache/store"
472
- start_heap_trace_if_requested
473
- end
474
-
475
- # Starts allocation tracing (RIGOR_HEAP_TRACE) as early as possible so
476
- # the heap profile can attribute retained Strings to their allocation
477
- # `file:line`. Very high overhead — run on a small file subset only.
478
- def start_heap_trace_if_requested
479
- return if ENV["RIGOR_HEAP_TRACE"].to_s.empty?
480
-
481
- require "objspace"
482
- ObjectSpace.trace_object_allocations_start
483
- end
484
-
485
- # When RIGOR_HEAP_TRACE is on, groups the live String objects by their
486
- # allocation site (`sourcefile:sourceline`) and prints the top sites by
487
- # count — pinpointing which engine code retains the millions of strings
488
- # that dominate the large-app heap (ADR-41 Slice 2b). Strings allocated
489
- # before tracing started report `(pre-trace)`.
490
- def write_string_allocation_sites
491
- return if ENV["RIGOR_HEAP_TRACE"].to_s.empty?
492
-
493
- by_site = Hash.new(0)
494
- ObjectSpace.each_object(String) do |str|
495
- file = ObjectSpace.allocation_sourcefile(str)
496
- line = ObjectSpace.allocation_sourceline(str)
497
- by_site[file ? "#{file}:#{line}" : "(pre-trace)"] += 1
498
- end
499
- @err.puts("")
500
- @err.puts(" String allocation sites (top 25 by live count)")
501
- by_site.sort_by { |_, n| -n }.first(25).each do |site, n|
502
- @err.puts(" #{n.to_s.rjust(9)} #{site}")
503
- end
504
- end
505
-
506
- # Walks the whole live heap (after a forced GC) and tallies
507
- # `{class_name => [count, memsize]}` plus the grand total. Returns
508
- # `[by_class, total]`. Slow — a dev probe only.
509
- def tally_live_heap
510
- require "objspace"
511
- GC.start
512
- by_class = Hash.new { |h, k| h[k] = [0, 0] }
513
- total = 0
514
- ObjectSpace.each_object do |obj|
515
- size = ObjectSpace.memsize_of(obj)
516
- entry = by_class[heap_class_name(obj)]
517
- entry[0] += 1
518
- entry[1] += size
519
- total += size
520
- end
521
- [by_class, total]
522
- end
523
-
524
- def heap_class_name(obj)
525
- klass = Object.instance_method(:class).bind_call(obj)
526
- klass.name || klass.inspect
527
- rescue StandardError
528
- "(unknown)"
529
- end
530
-
531
- def heap_mb(bytes)
532
- Kernel.format("%.1f MB", bytes / 1_048_576.0)
533
- end
534
-
535
- def write_cache_stats(cache_root, runtime_store)
536
- inv = Cache::Store.disk_inventory(root: cache_root)
537
-
538
- @out.puts("")
539
- @out.puts("Cache (root: #{inv.fetch(:root)})")
540
- schema = inv.fetch(:schema_version)
541
- @out.puts(" schema_version: #{schema.nil? ? 'absent' : schema}")
542
- write_disk_inventory(inv)
543
- write_runtime_stats(runtime_store) if runtime_store
544
- end
545
-
546
- def write_disk_inventory(inv)
547
- if inv.fetch(:total_entries).zero?
548
- @out.puts(" (empty)")
549
- return
550
- end
551
-
552
- @out.puts(" #{inv.fetch(:total_entries)} entries, #{format_bytes(inv.fetch(:total_bytes))}")
553
- inv.fetch(:producers).each do |producer|
554
- bytes = format_bytes(producer.fetch(:bytes))
555
- @out.puts(" #{producer.fetch(:id)}: #{producer.fetch(:entries)} entries, #{bytes}")
556
- end
557
- end
558
-
559
- def write_runtime_stats(store)
560
- stats = store.stats
561
- hits = stats.fetch(:hits)
562
- misses = stats.fetch(:misses)
563
- writes = stats.fetch(:writes)
564
- @out.puts(" this run: #{hits} #{plural(hits, 'hit')}, " \
565
- "#{misses} #{plural(misses, 'miss', 'misses')}, " \
566
- "#{writes} #{plural(writes, 'write')}")
567
- stats.fetch(:by_producer).each do |id, counts|
568
- @out.puts(" #{id}: #{counts.fetch(:hits)} #{plural(counts.fetch(:hits), 'hit')}, " \
569
- "#{counts.fetch(:misses)} #{plural(counts.fetch(:misses), 'miss', 'misses')}, " \
570
- "#{counts.fetch(:writes)} #{plural(counts.fetch(:writes), 'write')}")
571
- end
572
- end
573
-
574
- def plural(count, singular, plural = "#{singular}s")
575
- count == 1 ? singular : plural
576
- end
577
-
578
- def format_bytes(bytes)
579
- return "#{bytes} B" if bytes < 1024
580
- return format("%.1f KiB", bytes / 1024.0) if bytes < 1024 * 1024
581
-
582
- format("%.1f MiB", bytes / (1024.0 * 1024.0))
86
+ CheckCommand.new(argv: @argv, out: @out, err: @err).run
583
87
  end
584
88
 
585
89
  def run_init
@@ -696,6 +200,12 @@ module Rigor
696
200
  TypeOfCommand.new(argv: @argv, out: @out, err: @err).run
697
201
  end
698
202
 
203
+ def run_trace
204
+ require_relative "cli/trace_command"
205
+
206
+ TraceCommand.new(argv: @argv, out: @out, err: @err).run
207
+ end
208
+
699
209
  def run_type_scan
700
210
  require_relative "cli/type_scan_command"
701
211
 
@@ -779,34 +289,6 @@ module Rigor
779
289
  CLI::PluginCommand.new(argv: @argv, out: @out, err: @err).run
780
290
  end
781
291
 
782
- def write_result(result, format)
783
- case format
784
- when "json"
785
- @out.puts(JSON.pretty_generate(result.to_h))
786
- when "text"
787
- write_text_result(result)
788
- else
789
- raise OptionParser::InvalidArgument, "unsupported format: #{format}"
790
- end
791
- end
792
-
793
- # Text output adds a one-line summary so users see the
794
- # diagnostic-count immediately. The summary distinguishes
795
- # the success and failure cases and reports the affected
796
- # file count for failures.
797
- def write_text_result(result)
798
- result.diagnostics.each { |diagnostic| @out.puts(diagnostic) }
799
-
800
- if result.success?
801
- @out.puts("No diagnostics") if result.diagnostics.empty?
802
- return
803
- end
804
-
805
- error_files = result.diagnostics.select(&:error?).map(&:path).uniq.size
806
- @out.puts("")
807
- @out.puts("#{result.error_count} error(s) in #{error_files} file(s)")
808
- end
809
-
810
292
  def help
811
293
  <<~HELP
812
294
  Usage: rigor <command> [options]
@@ -816,6 +298,7 @@ module Rigor
816
298
  init Create a starter .rigor.yml
817
299
  annotate Print FILE with each line's last-expression type
818
300
  type-of Print the inferred type at FILE:LINE:COL
301
+ trace Replay how the engine typed FILE as a terminal animation
819
302
  type-scan Report Scope#type_of coverage across PATHs
820
303
  explain Print the description of one or all CheckRules
821
304
  diff Compare current diagnostics to a saved baseline JSON
@@ -77,7 +77,7 @@ module Rigor
77
77
  return new([]) if data.nil?
78
78
  raise ArgumentError, "dependencies: must be a Hash, got #{data.inspect}" unless data.is_a?(Hash)
79
79
 
80
- raw_entries = Array(data["source_inference"]).map { |raw| coerce_entry(raw) }
80
+ raw_entries = coerce_source_inference(data["source_inference"])
81
81
  entries, warnings = dedupe_entries(raw_entries)
82
82
  budget = coerce_budget_per_gem(data.fetch("budget_per_gem", DEFAULT_BUDGET_PER_GEM))
83
83
  strategy = coerce_budget_overrun_strategy(
@@ -158,6 +158,23 @@ module Rigor
158
158
 
159
159
  private
160
160
 
161
+ # `source_inference:` is a list of per-gem entries, or `false` /
162
+ # omitted to disable it (the default). Guard the Ruby
163
+ # `Array(false) == [false]` quirk that would otherwise feed `false`
164
+ # straight into coerce_entry and crash the whole run on a perfectly
165
+ # reasonable "off" config (`dependencies: { source_inference: false }`).
166
+ def coerce_source_inference(value)
167
+ return [] if value.nil? || value == false
168
+
169
+ unless value.is_a?(Array)
170
+ raise ArgumentError,
171
+ "dependencies.source_inference: must be a list of entries " \
172
+ "(or false / omitted to disable), got #{value.inspect}"
173
+ end
174
+
175
+ value.map { |raw| coerce_entry(raw) }
176
+ end
177
+
161
178
  def coerce_entry(raw)
162
179
  unless raw.is_a?(Hash)
163
180
  raise ArgumentError,