rigortype 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
  4. data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
  5. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +18 -1
  6. data/lib/rigor/analysis/check_rules.rb +34 -6
  7. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  8. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  9. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  10. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  11. data/lib/rigor/analysis/runner.rb +160 -1190
  12. data/lib/rigor/analysis/worker_session.rb +47 -8
  13. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  14. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  15. data/lib/rigor/cache/store.rb +46 -13
  16. data/lib/rigor/cli/check_command.rb +705 -0
  17. data/lib/rigor/cli/ci_detector.rb +94 -0
  18. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  19. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  20. data/lib/rigor/cli/trace_command.rb +143 -0
  21. data/lib/rigor/cli/trace_renderer.rb +310 -0
  22. data/lib/rigor/cli.rb +15 -614
  23. data/lib/rigor/configuration.rb +9 -6
  24. data/lib/rigor/environment/rbs_loader.rb +53 -68
  25. data/lib/rigor/environment.rb +1 -1
  26. data/lib/rigor/inference/acceptance.rb +10 -0
  27. data/lib/rigor/inference/expression_typer.rb +28 -62
  28. data/lib/rigor/inference/flow_tracer.rb +180 -0
  29. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  31. data/lib/rigor/inference/method_dispatcher.rb +115 -54
  32. data/lib/rigor/inference/narrowing.rb +60 -0
  33. data/lib/rigor/inference/scope_indexer.rb +75 -15
  34. data/lib/rigor/inference/statement_evaluator.rb +35 -52
  35. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  36. data/lib/rigor/plugin/base.rb +282 -41
  37. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  38. data/lib/rigor/plugin/registry.rb +263 -35
  39. data/lib/rigor/plugin.rb +1 -0
  40. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  41. data/lib/rigor/scope/discovery_index.rb +58 -0
  42. data/lib/rigor/scope.rb +67 -198
  43. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  44. data/lib/rigor/source/literals.rb +14 -0
  45. data/lib/rigor/type/combinator.rb +5 -0
  46. data/lib/rigor/version.rb +1 -1
  47. data/lib/rigor.rb +0 -1
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  49. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  50. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  53. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  54. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  55. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  56. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  57. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  58. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  59. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  60. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  61. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  62. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  63. data/sig/rigor/environment.rbs +0 -2
  64. data/sig/rigor/inference.rbs +5 -0
  65. data/sig/rigor/plugin/base.rbs +1 -2
  66. data/sig/rigor/scope.rbs +41 -29
  67. data/sig/rigor/source.rbs +1 -0
  68. data/skills/rigor-ci-setup/SKILL.md +319 -0
  69. metadata +15 -2
  70. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
data/lib/rigor/cli.rb CHANGED
@@ -10,13 +10,14 @@ require_relative "version"
10
10
  require_relative "analysis/diagnostic"
11
11
  require_relative "analysis/result"
12
12
  require_relative "cli/options"
13
+ require_relative "cli/diagnostic_formats"
14
+ require_relative "cli/ci_detector"
13
15
 
14
16
  module Rigor
15
17
  # The CLI class is a dispatcher: each `run_*` method delegates to a
16
18
  # command-specific class once the command grows beyond a few lines (see
17
- # {CLI::TypeOfCommand}). The class-length budget is intentionally relaxed
18
- # here so dispatch wiring can live alongside still-inlined commands.
19
- class CLI # rubocop:disable Metrics/ClassLength
19
+ # {CLI::TypeOfCommand} and {CLI::CheckCommand}).
20
+ class CLI
20
21
  EXIT_USAGE = 64
21
22
 
22
23
  HANDLERS = {
@@ -24,6 +25,7 @@ module Rigor
24
25
  "init" => :run_init,
25
26
  "annotate" => :run_annotate,
26
27
  "type-of" => :run_type_of,
28
+ "trace" => :run_trace,
27
29
  "type-scan" => :run_type_scan,
28
30
  "explain" => :run_explain,
29
31
  "diff" => :run_diff,
@@ -78,590 +80,10 @@ module Rigor
78
80
  EXIT_USAGE
79
81
  end
80
82
 
81
- def run_check # rubocop:disable Metrics/AbcSize
82
- load_check_dependencies
83
- options = parse_check_options
84
- buffer = Options.resolve_buffer_binding(options, err: @err)
85
- return EXIT_USAGE if buffer == :usage_error
86
-
87
- configuration = load_check_configuration(options)
88
- cache_root = configuration.cache_path
89
- handle_clear_cache(cache_root) if options.fetch(:clear_cache)
90
-
91
- special = dispatch_special_check_mode(configuration, options, cache_root)
92
- return special unless special.nil?
93
-
94
- runner = build_check_runner(
95
- configuration: configuration, options: options,
96
- buffer: buffer, cache_root: cache_root
97
- )
98
- raw_result = runner.run(@argv.empty? ? configuration.paths : @argv)
99
- result = apply_baseline_filter(raw_result, configuration, options)
100
-
101
- write_result(result, options.fetch(:format))
102
- write_run_stats(result.stats) if result.stats
103
- write_trace_appendices
104
- runner.cache_store&.evict!
105
- write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
106
-
107
- exit_code = result.success? ? 0 : 1
108
- exit_code = 1 if baseline_strict_violation?(raw_result.diagnostics, configuration, options)
109
- exit_code
110
- end
111
-
112
- # ADR-46 — the two incremental-analysis check modes both fully handle
113
- # the run and return an exit code (so `run_check` short-circuits);
114
- # returns nil for an ordinary check.
115
- def dispatch_special_check_mode(configuration, options, cache_root)
116
- return run_verify_incremental(configuration) if options.fetch(:verify_incremental)
117
- return run_incremental_check(configuration, options, cache_root) if options.fetch(:incremental)
118
-
119
- nil
120
- end
121
-
122
- # ADR-46 — the incremental-analysis acceptance gate. Runs a baseline
123
- # analysis (recording cross-file dependencies), then re-analyzes a
124
- # representative subset of files and serves the rest from the per-file
125
- # cache (the body tier), and asserts the merged diagnostics are
126
- # byte-identical to a full `--no-cache` analysis. A mismatch means the
127
- # incremental machinery would serve a stale — manufactured —
128
- # diagnostic, the soundness failure this gate exists to catch. Prints a
129
- # one-line PASS (exit 0) or the differing diagnostics (exit 1).
130
- def run_verify_incremental(configuration)
131
- paths = @argv.empty? ? nil : @argv
132
- session = Analysis::IncrementalSession.new(configuration: configuration, paths: paths)
133
- session.baseline
134
- analyzed = session.analyzed_files
135
-
136
- # Every other file forms the re-analyzed subset, so the run exercises
137
- # BOTH the subset-analysis path and the cache-serving path.
138
- subset = analyzed.each_with_index.select { |_, index| index.even? }.map(&:first)
139
- incremental = normalize_diagnostics(session.reanalyze_subset(subset))
140
- full = normalize_diagnostics(verify_full_diagnostics(configuration, paths))
141
-
142
- report_verify_incremental(incremental, full, subset_size: subset.size, total: analyzed.size)
143
- end
144
-
145
- # ADR-46 — cross-process incremental analysis (`--incremental`). Derives
146
- # the global fingerprint cheaply (no RBS env build), loads the disk
147
- # snapshot, and on a fingerprint hit re-analyzes only the files changed
148
- # since the last run (plus their dependents), serving the rest from the
149
- # snapshot; on a miss runs a full baseline. Persists the updated
150
- # snapshot for the next invocation. Diagnostics are identical to a full
151
- # run (the `--verify-incremental` gate enforces this); the win is
152
- # skipping per-file inference for unchanged files.
153
- def run_incremental_check(configuration, options, cache_root)
154
- paths = @argv.empty? ? nil : @argv
155
- probe = Analysis::Runner.new(configuration: configuration, cache_store: nil)
156
- files = paths ? probe.analysis_file_set(paths) : probe.analysis_file_set
157
- fingerprint = Cache::IncrementalSnapshot.fingerprint(
158
- configuration: configuration, roots: paths || configuration.paths
159
- )
160
- snapshot = Cache::IncrementalSnapshot.new(root: cache_root)
161
- session = Analysis::IncrementalSession.new(configuration: configuration, paths: paths)
162
-
163
- diagnostics, warm = session.run_incremental(snapshot: snapshot, fingerprint: fingerprint)
164
- @err.puts("rigor: --incremental #{warm ? 'warm — reused cached diagnostics' : 'cold — full analysis'} " \
165
- "(#{files.size} files)")
166
-
167
- result = apply_baseline_filter(Analysis::Result.new(diagnostics: diagnostics, stats: nil), configuration, options)
168
- write_result(result, options.fetch(:format))
169
- result.success? ? 0 : 1
170
- end
171
-
172
- def verify_full_diagnostics(configuration, paths)
173
- runner = Analysis::Runner.new(configuration: configuration, cache_store: nil)
174
- (paths ? runner.run(paths) : runner.run).diagnostics
175
- end
176
-
177
- def normalize_diagnostics(diagnostics)
178
- diagnostics.map(&:to_h).sort_by do |hash|
179
- [hash["path"].to_s, hash["line"].to_i, hash["column"].to_i, hash["rule"].to_s, hash["message"].to_s]
180
- end
181
- end
182
-
183
- def report_verify_incremental(incremental, full, subset_size:, total:)
184
- if incremental == full
185
- @out.puts("rigor: --verify-incremental OK — incremental " \
186
- "(#{subset_size}/#{total} files re-analyzed, rest from cache) " \
187
- "matches full (#{full.size} diagnostics)")
188
- return 0
189
- end
190
-
191
- only_incremental = incremental - full
192
- only_full = full - incremental
193
- @err.puts("rigor: --verify-incremental FAILED — incremental and full diagnostics differ.")
194
- @err.puts(" incremental-only: #{only_incremental.size}, full-only: #{only_full.size}")
195
- (only_incremental + only_full).first(10).each do |hash|
196
- @err.puts(" #{hash['path']}:#{hash['line']}:#{hash['column']}: [#{hash['rule']}] #{hash['message']}")
197
- end
198
- 1
199
- end
200
-
201
- # ADR-22 slice 5 — the `--baseline-strict` CI gate. When the
202
- # flag is set, ANY baseline drift fails the run — not only
203
- # excess drift (a bucket over threshold, which already fails
204
- # via the surfaced diagnostics) but also DEFICIT drift
205
- # (`actual < count`: the baseline has grown looser than the
206
- # code and should be regenerated). A no-op, with a stderr
207
- # note, when no baseline is active — the flag never
208
- # implicitly loads a baseline the config did not name (WD2).
209
- def baseline_strict_violation?(raw_diagnostics, configuration, options)
210
- return false unless options.fetch(:baseline_strict)
211
-
212
- path = resolve_baseline_path(configuration, options)
213
- if path.nil?
214
- @err.puts("rigor: --baseline-strict given but no baseline is active; nothing to gate.")
215
- return false
216
- end
217
-
218
- baseline = Analysis::Baseline.load(path, project_root: Dir.pwd)
219
- return false if baseline.nil? || baseline.empty?
83
+ def run_check
84
+ require_relative "cli/check_command"
220
85
 
221
- drifted = baseline.audit(raw_diagnostics).reject { |row| row.status == :within }
222
- return false if drifted.empty?
223
-
224
- report_strict_drift(drifted, path)
225
- true
226
- rescue Analysis::Baseline::LoadError => e
227
- @err.puts("rigor: baseline load failed: #{e.message} (--baseline-strict gate skipped)")
228
- false
229
- end
230
-
231
- def report_strict_drift(rows, path)
232
- @err.puts("rigor: --baseline-strict — #{rows.size} bucket(s) drifted from #{path}:")
233
- rows.sort_by { |r| [r.bucket.file, r.bucket.rule] }.each do |row|
234
- delta = row.delta.positive? ? "+#{row.delta}" : row.delta.to_s
235
- @err.puts(" #{row.bucket.file} [#{row.bucket.rule}] " \
236
- "#{row.bucket.count} → #{row.actual_count} (Δ#{delta}, #{row.status})")
237
- end
238
- @err.puts("rigor: run `rigor baseline regenerate` to refresh the baseline.")
239
- end
240
-
241
- # ADR-22 — apply the baseline filter as the LAST step of
242
- # the diagnostic pipeline (after `# rigor:disable`,
243
- # `severity_profile`, etc. — WD6). Resolution order
244
- # follows WD2 (b):
245
- #
246
- # 1. --no-baseline on the CLI → no baseline.
247
- # 2. --baseline=PATH on the CLI → load that path.
248
- # 3. .rigor.yml's `baseline: <path>` → load that path.
249
- # 4. otherwise → no baseline.
250
- #
251
- # When the path resolves and loads successfully, the filter
252
- # replaces `result.diagnostics` with the surfaced set and
253
- # writes a one-line summary to stderr (WD7) when any
254
- # diagnostics were silenced. Load failures emit a warning
255
- # to stderr and fall through to "no baseline" (graceful
256
- # degradation).
257
- def apply_baseline_filter(result, configuration, options)
258
- path = resolve_baseline_path(configuration, options)
259
- return result if path.nil?
260
-
261
- baseline = Analysis::Baseline.load(path, project_root: Dir.pwd)
262
- return result if baseline.nil?
263
-
264
- surfaced, silenced_count = baseline.filter(result.diagnostics)
265
- report_baseline_summary(silenced_count, path) if silenced_count.positive?
266
- Analysis::Result.new(diagnostics: surfaced, stats: result.stats)
267
- rescue Analysis::Baseline::LoadError => e
268
- @err.puts("rigor: baseline load failed: #{e.message} (continuing without baseline)")
269
- result
270
- end
271
-
272
- # WD2 (b) — resolve effective baseline path.
273
- def resolve_baseline_path(configuration, options)
274
- cli_value = options.fetch(:baseline)
275
- case cli_value
276
- when false then nil # --no-baseline
277
- when :unset then configuration.baseline_path # fall through to config
278
- else cli_value # --baseline=PATH
279
- end
280
- end
281
-
282
- def report_baseline_summary(silenced_count, baseline_path)
283
- @err.puts("rigor: #{silenced_count} diagnostic(s) silenced by baseline #{baseline_path}")
284
- end
285
-
286
- def build_check_runner(configuration:, options:, buffer:, cache_root:)
287
- cache_store = if options.fetch(:no_cache)
288
- nil
289
- else
290
- Cache::Store.new(
291
- root: cache_root,
292
- max_bytes: configuration.cache_max_bytes
293
- )
294
- end
295
- Analysis::Runner.new(
296
- configuration: configuration,
297
- explain: options.fetch(:explain),
298
- cache_store: cache_store,
299
- collect_stats: options.fetch(:stats),
300
- workers: resolve_workers(options, configuration),
301
- buffer: buffer
302
- )
303
- end
304
-
305
- # ADR-15 Phase 4c — resolves the worker count by
306
- # precedence: CLI `--workers=N` (most explicit) > env
307
- # `RIGOR_RACTOR_WORKERS` > config `.rigor.yml`
308
- # `parallel.workers:` > 0 (sequential default). Returns
309
- # an Integer; non-numeric values raise so typos fail
310
- # loudly. CLI / env may pass a negative value — clamped
311
- # to 0 (sequential) so a stray `-1` doesn't crash the
312
- # pool spawn loop.
313
- def resolve_workers(options, configuration)
314
- cli_value = options[:workers]
315
- return [Integer(cli_value), 0].max if cli_value
316
-
317
- env_value = ENV.fetch("RIGOR_RACTOR_WORKERS", nil)
318
- return [Integer(env_value), 0].max if env_value && !env_value.empty?
319
-
320
- configuration.parallel_workers
321
- end
322
-
323
- def parse_check_options # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
324
- options = {
325
- # `nil` triggers `Configuration.discover` (`.rigor.yml` then
326
- # `.rigor.dist.yml`); an explicit `--config=PATH` overrides.
327
- config: nil,
328
- format: "text",
329
- explain: false,
330
- cache_stats: false,
331
- clear_cache: false,
332
- no_cache: false,
333
- # Run-stats summary (target files, RBS class universe
334
- # breakdown, wall time, peak RSS) is on by default
335
- # because collection is ~free (single syscall for RSS,
336
- # one walk of `class_decl_paths` for the breakdown).
337
- # `--no-stats` suppresses it for callers that want a
338
- # diagnostic-only output stream.
339
- stats: true,
340
- # ADR-15 Phase 4c — when nil, falls back to
341
- # `RIGOR_RACTOR_WORKERS` then `.rigor.yml`
342
- # `parallel.workers:` then 0 (sequential). See
343
- # `resolve_workers` for the precedence chain.
344
- workers: nil,
345
- # Editor mode (`docs/design/20260516-editor-mode.md`).
346
- # Both must appear together; the runner uses the pair
347
- # to bind an in-flight buffer file to its logical path.
348
- tmp_file: nil,
349
- instead_of: nil,
350
- # ADR-22 — baseline filter. `:unset` means "fall through
351
- # to `.rigor.yml`'s `baseline:` key"; a String overrides
352
- # the config; `false` (from `--no-baseline`) suppresses
353
- # any baseline that the config might name.
354
- baseline: :unset,
355
- # ADR-22 slice 5 — `--baseline-strict` CI gate: fail the
356
- # run on any baseline drift, in either direction.
357
- baseline_strict: false,
358
- # ADR-32 WD10 carry-over — `--treat-all-as-inline-rbs`
359
- # forces the `rigor-rbs-inline` plugin into the loaded
360
- # plugin set with `require_magic_comment: false` so a
361
- # single ad-hoc `rigor check` invocation treats every
362
- # analysed file as inline-RBS without the user editing
363
- # `.rigor.yml`. Intended for single-file / ad-hoc CI use;
364
- # ordinary projects should configure the plugin in
365
- # `.rigor.yml`.
366
- treat_all_as_inline_rbs: false,
367
- # ADR-46 — the incremental-analysis acceptance gate. Runs a
368
- # baseline analysis, re-analyzes a subset and serves the rest from
369
- # the per-file cache, and asserts the merged diagnostics are
370
- # byte-identical to a full `--no-cache` run. Exits non-zero on any
371
- # mismatch. Off by default.
372
- verify_incremental: false,
373
- # ADR-46 — cross-process incremental analysis. With a disk snapshot
374
- # of the prior run's per-file diagnostics + dependency graph,
375
- # re-analyzes only the changed closure and serves the rest from the
376
- # snapshot. Off by default.
377
- incremental: false
378
- }
379
- parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
380
- opts.banner = "Usage: rigor check [options] [paths]"
381
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
382
- opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
383
- opts.on("--explain", "Surface fail-soft fallback events as :info diagnostics") { options[:explain] = true }
384
- opts.on("--cache-stats", "Print on-disk cache inventory at end of run") { options[:cache_stats] = true }
385
- opts.on("--clear-cache", "Remove the .rigor/cache directory before running") { options[:clear_cache] = true }
386
- opts.on("--no-cache", "Disable the persistent cache for this run") { options[:no_cache] = true }
387
- opts.on("--[no-]stats",
388
- "Print run summary (files, classes, memory, wall time) to stderr (default: on)") do |value|
389
- options[:stats] = value
390
- end
391
- opts.on("--workers=N", Integer,
392
- "Dispatch per-file analysis across N Ractor workers (default: 0; sequential)") do |value|
393
- options[:workers] = value
394
- end
395
- Options.add_editor_mode(opts, options)
396
- opts.on("--baseline=PATH",
397
- "ADR-22: load baseline from PATH (overrides .rigor.yml `baseline:`)") do |value|
398
- options[:baseline] = value
399
- end
400
- opts.on("--no-baseline",
401
- "ADR-22: ignore any configured baseline for this run") do
402
- options[:baseline] = false
403
- end
404
- opts.on("--baseline-strict",
405
- "ADR-22: fail the run on any baseline drift (CI gate)") do
406
- options[:baseline_strict] = true
407
- end
408
- opts.on("--treat-all-as-inline-rbs",
409
- "ADR-32: force-load rigor-rbs-inline with require_magic_comment: false") do
410
- options[:treat_all_as_inline_rbs] = true
411
- end
412
- opts.on("--verify-incremental",
413
- "ADR-46: assert incremental analysis matches a full run, then exit") do
414
- options[:verify_incremental] = true
415
- end
416
- opts.on("--incremental",
417
- "ADR-46: re-analyze only files changed since the last run (cross-process cache)") do
418
- options[:incremental] = true
419
- end
420
- end
421
- parser.parse!(@argv)
422
- options
423
- end
424
-
425
- # ADR-32 WD10 carry-over — wraps `Configuration.load` so the
426
- # CLI's `--treat-all-as-inline-rbs` flag can inject a
427
- # `rigor-rbs-inline` plugin entry with
428
- # `require_magic_comment: false` into the loaded plugin
429
- # set. Re-runs the include-aware YAML load and applies the
430
- # injection before `Configuration.new` so the new entry
431
- # follows the normal coercion path. A pre-existing
432
- # `rigor-rbs-inline` entry (by gem name or `id: rbs-inline`)
433
- # is removed first so the synthesised entry's
434
- # `require_magic_comment: false` wins unconditionally.
435
- def load_check_configuration(options)
436
- return Configuration.load(options.fetch(:config)) unless options.fetch(:treat_all_as_inline_rbs)
437
-
438
- path = options.fetch(:config) || Configuration.discover
439
- data = path && File.exist?(path) ? Configuration.load_with_includes(path) : {}
440
- data = data.dup
441
- data["plugins"] = inject_treat_all_as_inline_rbs(Array(data["plugins"]))
442
- Configuration.new(Configuration::DEFAULTS.merge(data))
443
- end
444
-
445
- def inject_treat_all_as_inline_rbs(entries)
446
- filtered = entries.reject { |entry| rigor_rbs_inline_entry?(entry) }
447
- filtered + [{
448
- "gem" => "rigor-rbs-inline",
449
- "id" => "rbs-inline",
450
- "config" => { "require_magic_comment" => false }
451
- }]
452
- end
453
-
454
- def rigor_rbs_inline_entry?(entry)
455
- case entry
456
- when String
457
- entry == "rigor-rbs-inline"
458
- when Hash
459
- string_keyed = entry.to_h { |k, v| [k.to_s, v] }
460
- string_keyed["gem"] == "rigor-rbs-inline" || string_keyed["id"] == "rbs-inline"
461
- else
462
- false
463
- end
464
- end
465
-
466
- def handle_clear_cache(cache_root)
467
- if File.directory?(cache_root)
468
- FileUtils.rm_rf(cache_root)
469
- @out.puts("Cleared cache: #{cache_root}")
470
- else
471
- @out.puts("Cache already empty: #{cache_root}")
472
- end
473
- end
474
-
475
- # Emits the {Analysis::RunStats} summary to STDERR so it
476
- # doesn't interleave with the diagnostic stream (text or
477
- # JSON) on STDOUT. JSON consumers can pipe stdout cleanly;
478
- # interactive users still see the summary on their tty.
479
- def write_run_stats(stats)
480
- @err.puts("")
481
- stats.format(@err)
482
- end
483
-
484
- # Opt-in developer diagnostics printed after the run: the
485
- # inference-cutoff trace (RIGOR_BUDGET_TRACE) and the heap-attribution
486
- # profile (RIGOR_HEAP_PROFILE). Each gates itself, so this is a no-op
487
- # on a normal run.
488
- def write_trace_appendices
489
- write_budget_trace
490
- write_heap_profile
491
- end
492
-
493
- # Dumps the opt-in inference-cutoff counters (RIGOR_BUDGET_TRACE).
494
- # These are the hard-coded "budget" guards that silently degrade
495
- # to `Dynamic[top]` / a fallback bound — counting them shows where
496
- # inference actually stopped. Process-global counters: meaningful
497
- # only on a single-process run (`--workers 0`), since they do not
498
- # cross fork boundaries.
499
- def write_budget_trace
500
- return unless Inference::BudgetTrace.enabled?
501
-
502
- counts = Inference::BudgetTrace.snapshot
503
- @err.puts("")
504
- @err.puts("Inference cutoffs (RIGOR_BUDGET_TRACE; --workers 0 for an exact count)")
505
- @err.puts(" recursion-guard hits: #{counts[Inference::BudgetTrace::RECURSION_GUARD]}")
506
- @err.puts(" ancestor-walk-limit hits: #{counts[Inference::BudgetTrace::ANCESTOR_WALK_LIMIT]}")
507
- @err.puts(" hkt-fuel-exhausted hits: #{counts[Inference::BudgetTrace::HKT_FUEL_EXHAUSTED]}")
508
- write_budget_distributions
509
- end
510
-
511
- # Dumps the read-only size distributions (ADR-41 Slice 2a). These
512
- # observe how large unions actually get, with no cap enforced — the
513
- # data the `union_size` budget default should be chosen from. The
514
- # `over` thresholds bracket the TypeProf prior (10) and Rigor's spec
515
- # default (24).
516
- def write_budget_distributions
517
- summary = Inference::BudgetTrace.summarize(Inference::BudgetTrace::UNION_ARITY, over: [10, 24, 40])
518
- pct = summary[:percentiles]
519
- @err.puts(" union arity: n=#{summary[:count]} max=#{summary[:max]} " \
520
- "p50=#{pct[:p50]} p90=#{pct[:p90]} p99=#{pct[:p99]}")
521
- over = summary[:over]
522
- @err.puts(" unions ≥10: #{over[10]} ≥24: #{over[24]} ≥40: #{over[40]}")
523
- end
524
-
525
- # Dumps a live-heap class breakdown (RIGOR_HEAP_PROFILE) — retained
526
- # objects by class after a forced GC, ranked by total memsize. The
527
- # tool for attributing where the analyzer's resident memory goes
528
- # (ADR-41 Slice 2b): it answers whether the heap is type carriers,
529
- # RBS objects, Prism nodes, or fact-store Hashes/Strings. Walking the
530
- # whole heap is slow — a dev probe, not a normal diagnostic. Run
531
- # single-process (`--workers 0`) so the parent heap is the analysis
532
- # heap; the gem is required lazily so a normal run never loads it.
533
- def write_heap_profile
534
- return if ENV["RIGOR_HEAP_PROFILE"].to_s.empty?
535
-
536
- by_class, total = tally_live_heap
537
- @err.puts("")
538
- @err.puts("Heap profile (RIGOR_HEAP_PROFILE; live objects after GC, by class)")
539
- @err.puts(" total tracked: #{heap_mb(total)} across #{by_class.size} classes")
540
- by_class.sort_by { |_, (_, bytes)| -bytes }.first(30).each do |name, (count, bytes)|
541
- @err.puts(" #{heap_mb(bytes).rjust(10)} #{count.to_s.rjust(9)} obj #{name}")
542
- end
543
- write_string_allocation_sites
544
- end
545
-
546
- # Loads the analysis-path dependencies lazily (so non-check commands
547
- # stay light) and starts heap-allocation tracing if requested, before
548
- # any analysis object is allocated.
549
- def load_check_dependencies
550
- require_relative "analysis/runner"
551
- require_relative "analysis/buffer_binding"
552
- require_relative "analysis/baseline"
553
- require_relative "cache/store"
554
- start_heap_trace_if_requested
555
- end
556
-
557
- # Starts allocation tracing (RIGOR_HEAP_TRACE) as early as possible so
558
- # the heap profile can attribute retained Strings to their allocation
559
- # `file:line`. Very high overhead — run on a small file subset only.
560
- def start_heap_trace_if_requested
561
- return if ENV["RIGOR_HEAP_TRACE"].to_s.empty?
562
-
563
- require "objspace"
564
- ObjectSpace.trace_object_allocations_start
565
- end
566
-
567
- # When RIGOR_HEAP_TRACE is on, groups the live String objects by their
568
- # allocation site (`sourcefile:sourceline`) and prints the top sites by
569
- # count — pinpointing which engine code retains the millions of strings
570
- # that dominate the large-app heap (ADR-41 Slice 2b). Strings allocated
571
- # before tracing started report `(pre-trace)`.
572
- def write_string_allocation_sites
573
- return if ENV["RIGOR_HEAP_TRACE"].to_s.empty?
574
-
575
- by_site = Hash.new(0)
576
- ObjectSpace.each_object(String) do |str|
577
- file = ObjectSpace.allocation_sourcefile(str)
578
- line = ObjectSpace.allocation_sourceline(str)
579
- by_site[file ? "#{file}:#{line}" : "(pre-trace)"] += 1
580
- end
581
- @err.puts("")
582
- @err.puts(" String allocation sites (top 25 by live count)")
583
- by_site.sort_by { |_, n| -n }.first(25).each do |site, n|
584
- @err.puts(" #{n.to_s.rjust(9)} #{site}")
585
- end
586
- end
587
-
588
- # Walks the whole live heap (after a forced GC) and tallies
589
- # `{class_name => [count, memsize]}` plus the grand total. Returns
590
- # `[by_class, total]`. Slow — a dev probe only.
591
- def tally_live_heap
592
- require "objspace"
593
- GC.start
594
- by_class = Hash.new { |h, k| h[k] = [0, 0] }
595
- total = 0
596
- ObjectSpace.each_object do |obj|
597
- size = ObjectSpace.memsize_of(obj)
598
- entry = by_class[heap_class_name(obj)]
599
- entry[0] += 1
600
- entry[1] += size
601
- total += size
602
- end
603
- [by_class, total]
604
- end
605
-
606
- def heap_class_name(obj)
607
- klass = Object.instance_method(:class).bind_call(obj)
608
- klass.name || klass.inspect
609
- rescue StandardError
610
- "(unknown)"
611
- end
612
-
613
- def heap_mb(bytes)
614
- Kernel.format("%.1f MB", bytes / 1_048_576.0)
615
- end
616
-
617
- def write_cache_stats(cache_root, runtime_store)
618
- inv = Cache::Store.disk_inventory(root: cache_root)
619
-
620
- @out.puts("")
621
- @out.puts("Cache (root: #{inv.fetch(:root)})")
622
- schema = inv.fetch(:schema_version)
623
- @out.puts(" schema_version: #{schema.nil? ? 'absent' : schema}")
624
- write_disk_inventory(inv)
625
- write_runtime_stats(runtime_store) if runtime_store
626
- end
627
-
628
- def write_disk_inventory(inv)
629
- if inv.fetch(:total_entries).zero?
630
- @out.puts(" (empty)")
631
- return
632
- end
633
-
634
- @out.puts(" #{inv.fetch(:total_entries)} entries, #{format_bytes(inv.fetch(:total_bytes))}")
635
- inv.fetch(:producers).each do |producer|
636
- bytes = format_bytes(producer.fetch(:bytes))
637
- @out.puts(" #{producer.fetch(:id)}: #{producer.fetch(:entries)} entries, #{bytes}")
638
- end
639
- end
640
-
641
- def write_runtime_stats(store)
642
- stats = store.stats
643
- hits = stats.fetch(:hits)
644
- misses = stats.fetch(:misses)
645
- writes = stats.fetch(:writes)
646
- @out.puts(" this run: #{hits} #{plural(hits, 'hit')}, " \
647
- "#{misses} #{plural(misses, 'miss', 'misses')}, " \
648
- "#{writes} #{plural(writes, 'write')}")
649
- stats.fetch(:by_producer).each do |id, counts|
650
- @out.puts(" #{id}: #{counts.fetch(:hits)} #{plural(counts.fetch(:hits), 'hit')}, " \
651
- "#{counts.fetch(:misses)} #{plural(counts.fetch(:misses), 'miss', 'misses')}, " \
652
- "#{counts.fetch(:writes)} #{plural(counts.fetch(:writes), 'write')}")
653
- end
654
- end
655
-
656
- def plural(count, singular, plural = "#{singular}s")
657
- count == 1 ? singular : plural
658
- end
659
-
660
- def format_bytes(bytes)
661
- return "#{bytes} B" if bytes < 1024
662
- return format("%.1f KiB", bytes / 1024.0) if bytes < 1024 * 1024
663
-
664
- format("%.1f MiB", bytes / (1024.0 * 1024.0))
86
+ CheckCommand.new(argv: @argv, out: @out, err: @err).run
665
87
  end
666
88
 
667
89
  def run_init
@@ -778,6 +200,12 @@ module Rigor
778
200
  TypeOfCommand.new(argv: @argv, out: @out, err: @err).run
779
201
  end
780
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
+
781
209
  def run_type_scan
782
210
  require_relative "cli/type_scan_command"
783
211
 
@@ -861,34 +289,6 @@ module Rigor
861
289
  CLI::PluginCommand.new(argv: @argv, out: @out, err: @err).run
862
290
  end
863
291
 
864
- def write_result(result, format)
865
- case format
866
- when "json"
867
- @out.puts(JSON.pretty_generate(result.to_h))
868
- when "text"
869
- write_text_result(result)
870
- else
871
- raise OptionParser::InvalidArgument, "unsupported format: #{format}"
872
- end
873
- end
874
-
875
- # Text output adds a one-line summary so users see the
876
- # diagnostic-count immediately. The summary distinguishes
877
- # the success and failure cases and reports the affected
878
- # file count for failures.
879
- def write_text_result(result)
880
- result.diagnostics.each { |diagnostic| @out.puts(diagnostic) }
881
-
882
- if result.success?
883
- @out.puts("No diagnostics") if result.diagnostics.empty?
884
- return
885
- end
886
-
887
- error_files = result.diagnostics.select(&:error?).map(&:path).uniq.size
888
- @out.puts("")
889
- @out.puts("#{result.error_count} error(s) in #{error_files} file(s)")
890
- end
891
-
892
292
  def help
893
293
  <<~HELP
894
294
  Usage: rigor <command> [options]
@@ -898,6 +298,7 @@ module Rigor
898
298
  init Create a starter .rigor.yml
899
299
  annotate Print FILE with each line's last-expression type
900
300
  type-of Print the inferred type at FILE:LINE:COL
301
+ trace Replay how the engine typed FILE as a terminal animation
901
302
  type-scan Report Scope#type_of coverage across PATHs
902
303
  explain Print the description of one or all CheckRules
903
304
  diff Compare current diagnostics to a saved baseline JSON