rigortype 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +186 -513
- data/lib/rigor/analysis/check_rules.rb +23 -1
- data/lib/rigor/analysis/diagnostic.rb +17 -3
- data/lib/rigor/analysis/runner.rb +178 -3
- data/lib/rigor/analysis/worker_session.rb +14 -3
- data/lib/rigor/cli/annotate_command.rb +224 -0
- data/lib/rigor/cli/baseline_command.rb +36 -16
- data/lib/rigor/cli/prism_colorizer.rb +111 -0
- data/lib/rigor/cli/triage_command.rb +83 -0
- data/lib/rigor/cli/triage_renderer.rb +77 -0
- data/lib/rigor/cli.rb +71 -5
- data/lib/rigor/environment.rb +9 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
- data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
- data/lib/rigor/inference/expression_typer.rb +300 -18
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +173 -10
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
- data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +33 -8
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +316 -2
- data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
- data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +179 -4
- data/lib/rigor/inference/method_parameter_binder.rb +67 -10
- data/lib/rigor/inference/narrowing.rb +29 -10
- data/lib/rigor/inference/scope_indexer.rb +156 -6
- data/lib/rigor/inference/statement_evaluator.rb +43 -21
- data/lib/rigor/plugin/base.rb +39 -0
- data/lib/rigor/plugin/loader.rb +22 -1
- data/lib/rigor/plugin/manifest.rb +73 -10
- data/lib/rigor/plugin/protocol_contract.rb +185 -0
- data/lib/rigor/plugin/registry.rb +66 -0
- data/lib/rigor/scope.rb +46 -0
- data/lib/rigor/triage/catalogue.rb +296 -0
- data/lib/rigor/triage/hint.rb +27 -0
- data/lib/rigor/triage.rb +89 -0
- data/lib/rigor/type/constant.rb +29 -2
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/inference.rbs +1 -0
- data/sig/rigor/scope.rbs +6 -0
- metadata +16 -1
|
@@ -355,6 +355,15 @@ module Rigor
|
|
|
355
355
|
class_name = concrete_class_name(receiver_type)
|
|
356
356
|
return nil if class_name.nil?
|
|
357
357
|
|
|
358
|
+
# ADR-26 — a plugin may declare a class "open": one
|
|
359
|
+
# known to respond beyond its RBS-declared method
|
|
360
|
+
# surface (e.g. `ActiveRecord::Relation`, which
|
|
361
|
+
# delegates an unbounded set of user-defined scopes to
|
|
362
|
+
# its model). Flagging an undefined method on a class
|
|
363
|
+
# with an open dynamic surface is unsound, so the rule
|
|
364
|
+
# skips it.
|
|
365
|
+
return nil if open_receiver?(class_name, scope)
|
|
366
|
+
|
|
358
367
|
# Slice 7 phase 12 — suppress when the user has
|
|
359
368
|
# declared the method in source (instance `def`,
|
|
360
369
|
# `def self.foo`, or recognised `define_method`).
|
|
@@ -424,6 +433,17 @@ module Rigor
|
|
|
424
433
|
nil
|
|
425
434
|
end
|
|
426
435
|
|
|
436
|
+
# ADR-26 — whether `class_name` is declared "open" by a
|
|
437
|
+
# loaded plugin (manifest `open_receivers:`). An open
|
|
438
|
+
# class responds beyond its RBS surface, so the
|
|
439
|
+
# `call.undefined-method` rule must not fire for it.
|
|
440
|
+
def open_receiver?(class_name, scope)
|
|
441
|
+
registry = scope.environment&.plugin_registry
|
|
442
|
+
return false if registry.nil?
|
|
443
|
+
|
|
444
|
+
registry.open_receiver?(class_name)
|
|
445
|
+
end
|
|
446
|
+
|
|
427
447
|
def definition_available?(receiver_type, class_name, scope)
|
|
428
448
|
if receiver_type.is_a?(Type::Singleton)
|
|
429
449
|
!Rigor::Reflection.singleton_definition(class_name, scope: scope).nil?
|
|
@@ -1215,7 +1235,9 @@ module Rigor
|
|
|
1215
1235
|
line: location.start_line,
|
|
1216
1236
|
column: location.start_column + 1,
|
|
1217
1237
|
message: "undefined method `#{call_node.name}' for #{rendered_receiver}",
|
|
1218
|
-
severity: :error
|
|
1238
|
+
severity: :error,
|
|
1239
|
+
receiver_type: rendered_receiver,
|
|
1240
|
+
method_name: call_node.name.to_s
|
|
1219
1241
|
)
|
|
1220
1242
|
end
|
|
1221
1243
|
|
|
@@ -8,7 +8,8 @@ module Rigor
|
|
|
8
8
|
# baseline against which non-default families are recognised.
|
|
9
9
|
DEFAULT_SOURCE_FAMILY = :builtin
|
|
10
10
|
|
|
11
|
-
attr_reader :path, :line, :column, :message, :severity, :rule, :source_family
|
|
11
|
+
attr_reader :path, :line, :column, :message, :severity, :rule, :source_family,
|
|
12
|
+
:receiver_type, :method_name
|
|
12
13
|
|
|
13
14
|
# `rule:` is the stable identifier (a kebab-case string)
|
|
14
15
|
# of the diagnostic's source rule. It is used by the
|
|
@@ -24,8 +25,19 @@ module Rigor
|
|
|
24
25
|
# ADR-2 § "Plugin Diagnostic Provenance") let consumers
|
|
25
26
|
# distinguish where a diagnostic originated without committing
|
|
26
27
|
# to the plugin API itself.
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
#
|
|
29
|
+
# `receiver_type:` / `method_name:` are optional structured
|
|
30
|
+
# fields populated by the call-related rules (`call.undefined-
|
|
31
|
+
# method`) — the rendered receiver type and the called method
|
|
32
|
+
# name as plain strings. ADR-23 WD3 / slice 4: `rigor triage`'s
|
|
33
|
+
# heuristic recognisers read these directly instead of parsing
|
|
34
|
+
# the diagnostic message, so the catalogue no longer couples to
|
|
35
|
+
# message wording. Both stay nil for rules that have no such
|
|
36
|
+
# pair; a consumer that finds them nil falls back to message
|
|
37
|
+
# parsing.
|
|
38
|
+
def initialize(path:, line:, column:, message:, severity: :error, rule: nil, # rubocop:disable Metrics/ParameterLists
|
|
39
|
+
source_family: DEFAULT_SOURCE_FAMILY,
|
|
40
|
+
receiver_type: nil, method_name: nil)
|
|
29
41
|
@path = path
|
|
30
42
|
@line = line
|
|
31
43
|
@column = column
|
|
@@ -33,6 +45,8 @@ module Rigor
|
|
|
33
45
|
@severity = severity
|
|
34
46
|
@rule = rule
|
|
35
47
|
@source_family = source_family
|
|
48
|
+
@receiver_type = receiver_type
|
|
49
|
+
@method_name = method_name
|
|
36
50
|
end
|
|
37
51
|
|
|
38
52
|
def error?
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
|
+
require "tmpdir"
|
|
4
5
|
|
|
5
6
|
require_relative "../environment"
|
|
6
7
|
require_relative "../scope"
|
|
@@ -104,6 +105,9 @@ module Rigor
|
|
|
104
105
|
@signature_paths_snapshot = [].freeze
|
|
105
106
|
@cached_plugin_prepare_diagnostics = [].freeze
|
|
106
107
|
@project_discovered_classes = {}.freeze
|
|
108
|
+
@project_discovered_def_nodes = {}.freeze
|
|
109
|
+
@project_discovered_superclasses = {}.freeze
|
|
110
|
+
@project_discovered_includes = {}.freeze
|
|
107
111
|
end
|
|
108
112
|
|
|
109
113
|
# ADR-pending editor mode — present when the runner is wired
|
|
@@ -241,6 +245,16 @@ module Rigor
|
|
|
241
245
|
# can share parses with the existing scanner passes.
|
|
242
246
|
@project_discovered_classes =
|
|
243
247
|
Inference::ScopeIndexer.discovered_classes_for_paths(expansion.fetch(:files), buffer: @buffer)
|
|
248
|
+
# ADR-24 slice 2 — cross-file def-node + class->superclass
|
|
249
|
+
# index so an implicit-self call inside a subclass
|
|
250
|
+
# resolves a superclass `def` declared in a sibling
|
|
251
|
+
# file. One extra parse pass over the project; shares
|
|
252
|
+
# the cost profile of the class-discovery pass above.
|
|
253
|
+
def_index =
|
|
254
|
+
Inference::ScopeIndexer.discovered_def_index_for_paths(expansion.fetch(:files), buffer: @buffer)
|
|
255
|
+
@project_discovered_def_nodes = def_index.fetch(:def_nodes)
|
|
256
|
+
@project_discovered_superclasses = def_index.fetch(:superclasses)
|
|
257
|
+
@project_discovered_includes = def_index.fetch(:includes)
|
|
244
258
|
end
|
|
245
259
|
|
|
246
260
|
# Internal: adopts a frozen {ProjectScan} snapshot supplied
|
|
@@ -278,7 +292,7 @@ module Rigor
|
|
|
278
292
|
return [] if files.empty?
|
|
279
293
|
|
|
280
294
|
if pool_mode?
|
|
281
|
-
|
|
295
|
+
dispatch_pool(files)
|
|
282
296
|
else
|
|
283
297
|
environment = resolve_sequential_environment
|
|
284
298
|
result = files.flat_map { |path| analyze_file(path, environment) }
|
|
@@ -460,6 +474,34 @@ module Rigor
|
|
|
460
474
|
@buffer.nil?
|
|
461
475
|
end
|
|
462
476
|
|
|
477
|
+
# ADR-15 Amendment (2026-05-20) — worker-pool backend selector.
|
|
478
|
+
# `fork` is the active backend: separate processes sidestep both
|
|
479
|
+
# the Ruby Bug #22075 use-after-free and the worker-side
|
|
480
|
+
# `Ractor::IsolationError` that make the Ractor pool unusable
|
|
481
|
+
# (see the ADR-15 Amendment +
|
|
482
|
+
# docs/notes/20260520-ractor-pool-cruby-uaf.md). The Ractor pool
|
|
483
|
+
# is preserved but off the default path — `RIGOR_POOL_BACKEND=ractor`
|
|
484
|
+
# opts back in so it stays testable. Platforms without `fork`
|
|
485
|
+
# (Windows) fall back to sequential.
|
|
486
|
+
def pool_backend
|
|
487
|
+
return :ractor if ENV["RIGOR_POOL_BACKEND"] == "ractor"
|
|
488
|
+
return :fork if Process.respond_to?(:fork)
|
|
489
|
+
|
|
490
|
+
:sequential
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Routes pool-mode analysis to the selected backend.
|
|
494
|
+
def dispatch_pool(files)
|
|
495
|
+
case pool_backend
|
|
496
|
+
when :ractor then analyze_files_in_pool(files)
|
|
497
|
+
when :fork then analyze_files_in_fork_pool(files)
|
|
498
|
+
else
|
|
499
|
+
analyze_files_sequentially_fallback(
|
|
500
|
+
files, reason: "fork-based parallelism is unavailable on this platform"
|
|
501
|
+
)
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
463
505
|
# Coordinator-side Environment used by the sequential code
|
|
464
506
|
# path. Pool mode builds one Environment per worker inside
|
|
465
507
|
# the worker Ractor's body instead.
|
|
@@ -598,6 +640,122 @@ module Rigor
|
|
|
598
640
|
Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
|
|
599
641
|
end
|
|
600
642
|
|
|
643
|
+
# ADR-15 Amendment (2026-05-20) — fork-based worker pool, the
|
|
644
|
+
# active backend for `workers > 0`. Builds ONE {WorkerSession}
|
|
645
|
+
# on the parent, then `fork`s N children that copy-on-write
|
|
646
|
+
# inherit it. Each child analyses a contiguous slice of `files`
|
|
647
|
+
# and writes a Marshal'd `{results:, reporters:}` payload to a
|
|
648
|
+
# temp file; the parent `Process.wait`s every child, merges the
|
|
649
|
+
# payloads, and re-orders diagnostics by original path order.
|
|
650
|
+
#
|
|
651
|
+
# Separate processes have separate GC heaps and `vm->ci_table`
|
|
652
|
+
# (immune to Ruby Bug #22075) and copy-on-write-inherit every
|
|
653
|
+
# constant (no `Ractor.shareable?` constraint). See the ADR-15
|
|
654
|
+
# Amendment + docs/notes/20260520-ractor-pool-cruby-uaf.md.
|
|
655
|
+
#
|
|
656
|
+
# A child that exits non-zero (crash / unmarshalable payload) is
|
|
657
|
+
# degraded: the parent re-analyses that slice in-process and
|
|
658
|
+
# prepends a `pool-degraded` warning.
|
|
659
|
+
def analyze_files_in_fork_pool(files) # rubocop:disable Metrics/AbcSize
|
|
660
|
+
Environment::ClassRegistry.default
|
|
661
|
+
|
|
662
|
+
session = WorkerSession.new(
|
|
663
|
+
configuration: @configuration,
|
|
664
|
+
cache_store: @cache_store,
|
|
665
|
+
plugin_blueprints: @plugin_registry.blueprints,
|
|
666
|
+
explain: @explain,
|
|
667
|
+
synthetic_method_index: @synthetic_method_index,
|
|
668
|
+
project_patched_methods: @project_patched_methods
|
|
669
|
+
)
|
|
670
|
+
# Force the full RBS load on the parent so children
|
|
671
|
+
# copy-on-write inherit a warm Environment rather than each
|
|
672
|
+
# rebuilding it after the fork.
|
|
673
|
+
session.environment.rbs_loader&.prewarm
|
|
674
|
+
snapshot_fork_pool_stats(session) if @collect_stats
|
|
675
|
+
|
|
676
|
+
worker_count = [@workers, files.size].min
|
|
677
|
+
slices = files.each_slice((files.size.to_f / worker_count).ceil).to_a
|
|
678
|
+
results_by_path = {}
|
|
679
|
+
|
|
680
|
+
degraded = Dir.mktmpdir("rigor-fork-pool") do |tmpdir|
|
|
681
|
+
children = slices.each_with_index.map do |slice, index|
|
|
682
|
+
out_path = File.join(tmpdir, "worker-#{index}")
|
|
683
|
+
{ pid: fork { run_fork_worker(session, slice, out_path) },
|
|
684
|
+
slice: slice, out_path: out_path }
|
|
685
|
+
end
|
|
686
|
+
collect_fork_results(children, results_by_path)
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
unless degraded.empty?
|
|
690
|
+
degraded.each { |path| results_by_path[path] = session.analyze(path) }
|
|
691
|
+
merge_worker_reporters(session.drain_reporters)
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
diagnostics = Array(session.prepare_diagnostics) +
|
|
695
|
+
files.flat_map { |path| results_by_path.fetch(path, []) }
|
|
696
|
+
degraded.empty? ? diagnostics : diagnostics.unshift(fork_degraded_diagnostic(degraded.size))
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# Child-process body for {#analyze_files_in_fork_pool}. Analyses
|
|
700
|
+
# the slice with the copy-on-write-inherited session and writes
|
|
701
|
+
# the Marshal'd payload to `out_path`. `exit!` skips `at_exit` /
|
|
702
|
+
# stdio flush — the payload is already durable on disk by then.
|
|
703
|
+
def run_fork_worker(session, slice, out_path)
|
|
704
|
+
results = slice.to_h { |path| [path, session.analyze(path)] }
|
|
705
|
+
payload = { results: results, reporters: session.drain_reporters }
|
|
706
|
+
File.binwrite(out_path, Marshal.dump(payload))
|
|
707
|
+
exit!(0)
|
|
708
|
+
rescue StandardError
|
|
709
|
+
exit!(1)
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
# Snapshots `class_decl_paths` from the parent session's loader
|
|
713
|
+
# so end-of-run {RunStats} can attribute the RBS class universe.
|
|
714
|
+
def snapshot_fork_pool_stats(session)
|
|
715
|
+
loader = session.environment.rbs_loader
|
|
716
|
+
@class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
|
|
717
|
+
@signature_paths_snapshot = loader&.signature_paths || [].freeze
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# Waits for every forked child, merges each successful payload
|
|
721
|
+
# into `results_by_path`, and returns the file paths whose
|
|
722
|
+
# worker exited abnormally (for in-process degrade).
|
|
723
|
+
def collect_fork_results(children, results_by_path)
|
|
724
|
+
degraded = []
|
|
725
|
+
children.each do |child|
|
|
726
|
+
_, status = Process.waitpid2(child[:pid])
|
|
727
|
+
payload = fork_worker_payload(status, child[:out_path])
|
|
728
|
+
if payload
|
|
729
|
+
results_by_path.merge!(payload.fetch(:results))
|
|
730
|
+
merge_worker_reporters(payload.fetch(:reporters))
|
|
731
|
+
else
|
|
732
|
+
degraded.concat(child[:slice])
|
|
733
|
+
end
|
|
734
|
+
end
|
|
735
|
+
degraded
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# @return [Hash, nil] the child's `{results:, reporters:}`
|
|
739
|
+
# payload, or nil when the child exited abnormally or wrote no
|
|
740
|
+
# readable payload. `Marshal.load` is safe here: the blob was
|
|
741
|
+
# written by our own forked child to a temp file we created.
|
|
742
|
+
def fork_worker_payload(status, out_path)
|
|
743
|
+
return nil unless status.success? && File.exist?(out_path)
|
|
744
|
+
|
|
745
|
+
Marshal.load(File.binread(out_path)) # rubocop:disable Security/MarshalLoad
|
|
746
|
+
rescue StandardError
|
|
747
|
+
nil
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
def fork_degraded_diagnostic(count)
|
|
751
|
+
Diagnostic.new(
|
|
752
|
+
path: ".rigor.yml", line: 1, column: 1,
|
|
753
|
+
message: "fork pool degraded: #{count} file(s) re-analysed in-process " \
|
|
754
|
+
"after a worker exited abnormally",
|
|
755
|
+
severity: :warning, rule: "pool-degraded", source_family: :builtin
|
|
756
|
+
)
|
|
757
|
+
end
|
|
758
|
+
|
|
601
759
|
# End-of-run telemetry. Walks the cached
|
|
602
760
|
# `class_decl_paths` snapshot (sequential mode: from
|
|
603
761
|
# the coordinator's environment; pool mode: from the
|
|
@@ -1221,12 +1379,29 @@ module Rigor
|
|
|
1221
1379
|
Prism.parse(File.read(physical), filepath: path, version: @configuration.target_ruby)
|
|
1222
1380
|
end
|
|
1223
1381
|
|
|
1382
|
+
# Seeds the cross-file project pre-pass indexes onto a
|
|
1383
|
+
# fresh per-file scope: discovered classes, and the ADR-24
|
|
1384
|
+
# def-node / superclass / included-module maps. Each is
|
|
1385
|
+
# applied only when non-empty so a runner constructed
|
|
1386
|
+
# without the project pre-pass (e.g. a single-file probe)
|
|
1387
|
+
# keeps an empty seed.
|
|
1388
|
+
def seed_project_scope(scope)
|
|
1389
|
+
scope = scope.with_discovered_classes(@project_discovered_classes) unless @project_discovered_classes.empty?
|
|
1390
|
+
unless @project_discovered_def_nodes.empty?
|
|
1391
|
+
scope = scope.with_discovered_def_nodes(@project_discovered_def_nodes)
|
|
1392
|
+
end
|
|
1393
|
+
unless @project_discovered_superclasses.empty?
|
|
1394
|
+
scope = scope.with_discovered_superclasses(@project_discovered_superclasses)
|
|
1395
|
+
end
|
|
1396
|
+
scope = scope.with_discovered_includes(@project_discovered_includes) unless @project_discovered_includes.empty?
|
|
1397
|
+
scope
|
|
1398
|
+
end
|
|
1399
|
+
|
|
1224
1400
|
def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
|
|
1225
1401
|
parse_result = parse_source(path)
|
|
1226
1402
|
return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
|
|
1227
1403
|
|
|
1228
|
-
scope = Scope.empty(environment: environment, source_path: path)
|
|
1229
|
-
scope = scope.with_discovered_classes(@project_discovered_classes) unless @project_discovered_classes.empty?
|
|
1404
|
+
scope = seed_project_scope(Scope.empty(environment: environment, source_path: path))
|
|
1230
1405
|
index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
|
|
1231
1406
|
diagnostics = CheckRules.diagnose(
|
|
1232
1407
|
path: path,
|
|
@@ -38,6 +38,12 @@ module Rigor
|
|
|
38
38
|
# - `plugin_blueprints` — Phase 3a
|
|
39
39
|
# (`Array<Plugin::Blueprint>` is `Ractor.shareable?`).
|
|
40
40
|
# - `explain` — Boolean.
|
|
41
|
+
# - `synthetic_method_index` / `project_patched_methods` —
|
|
42
|
+
# optional (default `nil`). NOT `Ractor.shareable?`, so the
|
|
43
|
+
# Ractor pool path leaves them unset; the fork backend
|
|
44
|
+
# (ADR-15 Amendment), which builds the session pre-fork on the
|
|
45
|
+
# parent, threads the runner's project-scan results through so
|
|
46
|
+
# per-file inference matches the sequential path exactly.
|
|
41
47
|
#
|
|
42
48
|
# Internally the session OWNS (and never shares):
|
|
43
49
|
#
|
|
@@ -70,7 +76,7 @@ module Rigor
|
|
|
70
76
|
# output (modulo severity-profile re-stamping, which the
|
|
71
77
|
# session leaves to the caller because it is a per-run
|
|
72
78
|
# aggregate concern).
|
|
73
|
-
class WorkerSession
|
|
79
|
+
class WorkerSession # rubocop:disable Metrics/ClassLength
|
|
74
80
|
attr_reader :configuration, :cache_store, :services, :plugin_registry,
|
|
75
81
|
:dependency_source_index, :environment,
|
|
76
82
|
:rbs_extended_reporter, :boundary_cross_reporter,
|
|
@@ -88,11 +94,14 @@ module Rigor
|
|
|
88
94
|
# directly-unrecognised node, mirroring
|
|
89
95
|
# {Rigor::Analysis::Runner#explain_diagnostics}.
|
|
90
96
|
def initialize(configuration:, cache_store: nil, # rubocop:disable Metrics/MethodLength
|
|
91
|
-
plugin_blueprints: [], explain: false, buffer: nil
|
|
97
|
+
plugin_blueprints: [], explain: false, buffer: nil,
|
|
98
|
+
synthetic_method_index: nil, project_patched_methods: nil)
|
|
92
99
|
@configuration = configuration
|
|
93
100
|
@cache_store = cache_store
|
|
94
101
|
@explain = explain
|
|
95
102
|
@buffer = buffer
|
|
103
|
+
@synthetic_method_index = synthetic_method_index
|
|
104
|
+
@project_patched_methods = project_patched_methods
|
|
96
105
|
|
|
97
106
|
# NOTE: `Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths`
|
|
98
107
|
# is process-global state. Writing it from a non-main
|
|
@@ -127,7 +136,9 @@ module Rigor
|
|
|
127
136
|
bundler_auto_detect: configuration.bundler_auto_detect,
|
|
128
137
|
bundler_lockfile: configuration.bundler_lockfile,
|
|
129
138
|
rbs_collection_lockfile: configuration.rbs_collection_lockfile,
|
|
130
|
-
rbs_collection_auto_detect: configuration.rbs_collection_auto_detect
|
|
139
|
+
rbs_collection_auto_detect: configuration.rbs_collection_auto_detect,
|
|
140
|
+
synthetic_method_index: @synthetic_method_index,
|
|
141
|
+
project_patched_methods: @project_patched_methods
|
|
131
142
|
)
|
|
132
143
|
@prepare_diagnostics = run_plugin_prepare.freeze
|
|
133
144
|
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optionparser"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
require_relative "../configuration"
|
|
7
|
+
require_relative "../environment"
|
|
8
|
+
require_relative "../scope"
|
|
9
|
+
require_relative "../inference/scope_indexer"
|
|
10
|
+
require_relative "prism_colorizer"
|
|
11
|
+
|
|
12
|
+
module Rigor
|
|
13
|
+
class CLI
|
|
14
|
+
# Executes `rigor annotate FILE`.
|
|
15
|
+
#
|
|
16
|
+
# For every source line the command finds the expression the
|
|
17
|
+
# line evaluates to — the last statement that ends on the line
|
|
18
|
+
# (so `1; 2; 3` reports `3`), or, for a line that no statement
|
|
19
|
+
# closes, the widest expression ending there (so the `if nil`
|
|
20
|
+
# header reports its condition). It infers that expression's
|
|
21
|
+
# type and appends a `#=> dump_type: <type>` comment.
|
|
22
|
+
#
|
|
23
|
+
# The annotated source is re-parsed with Prism — a sanity gate,
|
|
24
|
+
# since the appended text is always a comment — and printed to
|
|
25
|
+
# stdout with IRB-style syntax highlighting via
|
|
26
|
+
# {PrismColorizer}.
|
|
27
|
+
class AnnotateCommand
|
|
28
|
+
USAGE = "Usage: rigor annotate [options] FILE"
|
|
29
|
+
|
|
30
|
+
# Appended ` #=> dump_type: <type>` suffix. Matched and
|
|
31
|
+
# stripped before re-annotating so re-running is idempotent.
|
|
32
|
+
ANNOTATION_PATTERN = /\s*#=>\s*dump_type:.*\z/
|
|
33
|
+
|
|
34
|
+
def initialize(argv:, out:, err:)
|
|
35
|
+
@argv = argv
|
|
36
|
+
@out = out
|
|
37
|
+
@err = err
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Integer] CLI exit status.
|
|
41
|
+
def run
|
|
42
|
+
options = parse_options
|
|
43
|
+
file = @argv.shift
|
|
44
|
+
if file.nil?
|
|
45
|
+
@err.puts(USAGE)
|
|
46
|
+
return CLI::EXIT_USAGE
|
|
47
|
+
end
|
|
48
|
+
unless File.file?(file)
|
|
49
|
+
@err.puts("annotate: file not found: #{file}")
|
|
50
|
+
return 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
execute(file, options)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def parse_options
|
|
59
|
+
# Default: colour a tty, unless `NO_COLOR` opts out. An
|
|
60
|
+
# explicit `--color` / `--no-color` overrides both.
|
|
61
|
+
options = { config: nil, color: @out.tty? && !no_color_env? }
|
|
62
|
+
|
|
63
|
+
parser = OptionParser.new do |opts|
|
|
64
|
+
opts.banner = USAGE
|
|
65
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
|
|
66
|
+
opts.on("--[no-]color",
|
|
67
|
+
"Force or disable ANSI colour (default: auto-detect a tty; honours NO_COLOR)") do |value|
|
|
68
|
+
options[:color] = value
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
parser.parse!(@argv)
|
|
72
|
+
|
|
73
|
+
options
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# https://no-color.org — colour output is suppressed by
|
|
77
|
+
# default when `NO_COLOR` is present and not an empty string,
|
|
78
|
+
# regardless of its value.
|
|
79
|
+
def no_color_env?
|
|
80
|
+
value = ENV.fetch("NO_COLOR", nil)
|
|
81
|
+
!value.nil? && !value.empty?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def execute(file, options)
|
|
85
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
86
|
+
source = File.read(file)
|
|
87
|
+
parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
|
|
88
|
+
return 1 if parse_errors?(parse_result, file)
|
|
89
|
+
|
|
90
|
+
scope_index = Inference::ScopeIndexer.index(
|
|
91
|
+
parse_result.value, default_scope: base_scope(configuration)
|
|
92
|
+
)
|
|
93
|
+
line_types = LineTypeCollector.new(scope_index).collect(parse_result.value)
|
|
94
|
+
|
|
95
|
+
@out.puts(render(annotate(source, line_types), color: options.fetch(:color)))
|
|
96
|
+
0
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def base_scope(configuration)
|
|
100
|
+
Scope.empty(
|
|
101
|
+
environment: Environment.for_project(
|
|
102
|
+
libraries: configuration.libraries,
|
|
103
|
+
signature_paths: configuration.signature_paths
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse_errors?(parse_result, file)
|
|
109
|
+
return false if parse_result.success?
|
|
110
|
+
|
|
111
|
+
parse_result.errors.each do |error|
|
|
112
|
+
@err.puts("#{file}:#{error.location.start_line}: #{error.message}")
|
|
113
|
+
end
|
|
114
|
+
true
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Appends ` #=> dump_type: <type>` to every line a type was
|
|
118
|
+
# inferred for, aligning the comment column.
|
|
119
|
+
def annotate(source, line_types)
|
|
120
|
+
lines = source.lines
|
|
121
|
+
column = annotation_column(lines, line_types)
|
|
122
|
+
|
|
123
|
+
lines.each_with_index.map do |line, index|
|
|
124
|
+
type = line_types[index + 1]
|
|
125
|
+
eol = line.end_with?("\n") ? "\n" : ""
|
|
126
|
+
code = line.chomp.sub(ANNOTATION_PATTERN, "")
|
|
127
|
+
next "#{code}#{eol}" if type.nil?
|
|
128
|
+
|
|
129
|
+
"#{code.ljust(column)} #=> dump_type: #{type.describe(:short)}#{eol}"
|
|
130
|
+
end.join
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def annotation_column(lines, line_types)
|
|
134
|
+
widths = lines.each_index.filter_map do |index|
|
|
135
|
+
next unless line_types.key?(index + 1)
|
|
136
|
+
|
|
137
|
+
lines[index].chomp.sub(ANNOTATION_PATTERN, "").length
|
|
138
|
+
end
|
|
139
|
+
widths.max || 0
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def render(annotated, color:)
|
|
143
|
+
return annotated unless color
|
|
144
|
+
return annotated unless Prism.parse(annotated).success?
|
|
145
|
+
|
|
146
|
+
PrismColorizer.colorize(annotated)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Walks a parsed program and resolves, per source line, the
|
|
151
|
+
# type of the expression the line evaluates to. Used only by
|
|
152
|
+
# {AnnotateCommand}.
|
|
153
|
+
class LineTypeCollector
|
|
154
|
+
def initialize(scope_index)
|
|
155
|
+
@scope_index = scope_index
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @param program [Prism::ProgramNode]
|
|
159
|
+
# @return [Hash{Integer => Rigor::Type}] 1-indexed line => type.
|
|
160
|
+
def collect(program)
|
|
161
|
+
by_line = {}
|
|
162
|
+
each_statement(program) do |statement|
|
|
163
|
+
type = type_of(statement)
|
|
164
|
+
by_line[statement.location.end_line] = type unless type.nil?
|
|
165
|
+
end
|
|
166
|
+
fill_uncovered_lines(program, by_line)
|
|
167
|
+
by_line
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
# Yields each statement node (a child of any `StatementsNode`
|
|
173
|
+
# anywhere in the tree) in source order, so a later statement
|
|
174
|
+
# ending on a line overwrites an earlier one — `1; 2; 3`
|
|
175
|
+
# resolves to `3`.
|
|
176
|
+
def each_statement(node, &)
|
|
177
|
+
return if node.nil?
|
|
178
|
+
|
|
179
|
+
node.body.each(&) if node.is_a?(Prism::StatementsNode)
|
|
180
|
+
node.compact_child_nodes.each { |child| each_statement(child, &) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# For a line no statement closes (the `if` / block header
|
|
184
|
+
# lines), fall back to the widest expression ending there.
|
|
185
|
+
def fill_uncovered_lines(program, by_line)
|
|
186
|
+
widest_per_line(program).each do |line, node|
|
|
187
|
+
next if by_line.key?(line)
|
|
188
|
+
|
|
189
|
+
type = type_of(node)
|
|
190
|
+
by_line[line] = type unless type.nil?
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def widest_per_line(program)
|
|
195
|
+
widest = {}
|
|
196
|
+
walk(program) do |node|
|
|
197
|
+
next if node.is_a?(Prism::ProgramNode) || node.is_a?(Prism::StatementsNode)
|
|
198
|
+
|
|
199
|
+
line = node.location.end_line
|
|
200
|
+
current = widest[line]
|
|
201
|
+
widest[line] = node if current.nil? || span(node) > span(current)
|
|
202
|
+
end
|
|
203
|
+
widest
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def span(node)
|
|
207
|
+
node.location.end_offset - node.location.start_offset
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def walk(node, &block)
|
|
211
|
+
return if node.nil?
|
|
212
|
+
|
|
213
|
+
block.call(node)
|
|
214
|
+
node.compact_child_nodes.each { |child| walk(child, &block) }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def type_of(node)
|
|
218
|
+
@scope_index[node].type_of(node)
|
|
219
|
+
rescue StandardError
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|