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.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
- data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +226 -0
- data/lib/rigor/analysis/check_rules.rb +180 -73
- data/lib/rigor/analysis/dependency_recorder.rb +122 -0
- data/lib/rigor/analysis/diagnostic.rb +18 -0
- data/lib/rigor/analysis/incremental.rb +162 -0
- data/lib/rigor/analysis/incremental_session.rb +337 -0
- data/lib/rigor/analysis/rule_catalog.rb +48 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
- data/lib/rigor/analysis/runner.rb +477 -1110
- data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
- data/lib/rigor/analysis/worker_session.rb +47 -8
- data/lib/rigor/builtins/static_return_refinements.rb +7 -1
- data/lib/rigor/cache/descriptor.rb +50 -49
- data/lib/rigor/cache/incremental_snapshot.rb +153 -0
- data/lib/rigor/cache/rbs_cache_producer.rb +34 -0
- data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
- data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
- data/lib/rigor/cache/rbs_constant_table.rb +2 -8
- data/lib/rigor/cache/rbs_environment.rb +2 -8
- data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
- data/lib/rigor/cache/store.rb +145 -14
- data/lib/rigor/cli/annotate_command.rb +2 -7
- data/lib/rigor/cli/baseline_command.rb +2 -7
- data/lib/rigor/cli/check_command.rb +705 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/command.rb +47 -0
- data/lib/rigor/cli/coverage_command.rb +3 -23
- data/lib/rigor/cli/coverage_renderer.rb +3 -8
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/diff_command.rb +3 -7
- data/lib/rigor/cli/explain_command.rb +2 -7
- data/lib/rigor/cli/lsp_command.rb +3 -7
- data/lib/rigor/cli/mcp_command.rb +3 -7
- data/lib/rigor/cli/options.rb +57 -0
- data/lib/rigor/cli/plugin_command.rb +3 -7
- data/lib/rigor/cli/plugins_command.rb +2 -7
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/renderable.rb +26 -0
- data/lib/rigor/cli/sig_gen_command.rb +2 -7
- data/lib/rigor/cli/skill_command.rb +3 -7
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli/triage_command.rb +2 -7
- data/lib/rigor/cli/type_of_command.rb +5 -38
- data/lib/rigor/cli/type_of_renderer.rb +4 -9
- data/lib/rigor/cli/type_scan_command.rb +3 -23
- data/lib/rigor/cli/type_scan_renderer.rb +4 -9
- data/lib/rigor/cli.rb +15 -532
- data/lib/rigor/configuration/dependencies.rb +18 -1
- data/lib/rigor/configuration/severity_profile.rb +22 -3
- data/lib/rigor/configuration.rb +16 -3
- data/lib/rigor/environment/rbs_loader.rb +129 -71
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -0
- data/lib/rigor/inference/block_parameter_binder.rb +1 -2
- data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
- data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
- data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
- data/lib/rigor/inference/expression_typer.rb +149 -63
- data/lib/rigor/inference/flow_tracer.rb +180 -0
- data/lib/rigor/inference/macro_block_self_type.rb +10 -11
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
- data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
- data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
- data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
- data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
- data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
- data/lib/rigor/inference/method_dispatcher.rb +185 -84
- data/lib/rigor/inference/narrowing.rb +262 -5
- data/lib/rigor/inference/scope_indexer.rb +208 -21
- data/lib/rigor/inference/statement_evaluator.rb +110 -48
- data/lib/rigor/language_server/buffer_resolution.rb +33 -0
- data/lib/rigor/language_server/completion_provider.rb +4 -4
- data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
- data/lib/rigor/language_server/folding_range_provider.rb +4 -4
- data/lib/rigor/language_server/hover_provider.rb +4 -4
- data/lib/rigor/language_server/selection_range_provider.rb +4 -4
- data/lib/rigor/language_server/signature_help_provider.rb +4 -4
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +302 -45
- data/lib/rigor/plugin/node_rule_walk.rb +147 -0
- data/lib/rigor/plugin/registry.rb +281 -15
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +293 -0
- data/lib/rigor/rbs_extended.rb +39 -0
- data/lib/rigor/scope/discovery_index.rb +58 -0
- data/lib/rigor/scope.rb +150 -167
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/type/acceptance_router.rb +19 -0
- data/lib/rigor/type/accepts_result.rb +3 -10
- data/lib/rigor/type/app.rb +3 -7
- data/lib/rigor/type/bot.rb +2 -3
- data/lib/rigor/type/bound_method.rb +5 -12
- data/lib/rigor/type/combinator.rb +22 -0
- data/lib/rigor/type/constant.rb +2 -3
- data/lib/rigor/type/data_class.rb +80 -0
- data/lib/rigor/type/data_instance.rb +100 -0
- data/lib/rigor/type/difference.rb +5 -10
- data/lib/rigor/type/dynamic.rb +5 -10
- data/lib/rigor/type/hash_shape.rb +5 -15
- data/lib/rigor/type/integer_range.rb +5 -10
- data/lib/rigor/type/intersection.rb +5 -10
- data/lib/rigor/type/nominal.rb +5 -10
- data/lib/rigor/type/refined.rb +5 -10
- data/lib/rigor/type/singleton.rb +5 -10
- data/lib/rigor/type/top.rb +2 -3
- data/lib/rigor/type/tuple.rb +5 -10
- data/lib/rigor/type/union.rb +5 -10
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/value_semantics.rb +77 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
- data/sig/rigor/cache.rbs +19 -0
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference.rbs +27 -0
- data/sig/rigor/plugin/base.rbs +1 -2
- data/sig/rigor/rbs_extended.rbs +2 -0
- data/sig/rigor/scope.rbs +42 -25
- data/sig/rigor/source.rbs +1 -0
- data/sig/rigor/type.rbs +58 -1
- data/sig/rigor.rbs +6 -1
- data/skills/rigor-ci-setup/SKILL.md +319 -0
- metadata +36 -2
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -79
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
3
4
|
require "prism"
|
|
4
5
|
require "tmpdir"
|
|
5
6
|
|
|
6
7
|
require_relative "../environment"
|
|
7
8
|
require_relative "../scope"
|
|
8
9
|
require_relative "../cache/store"
|
|
10
|
+
require_relative "../cache/rbs_descriptor"
|
|
9
11
|
require_relative "../plugin"
|
|
10
12
|
require_relative "../plugin/source_rbs_synthesis_reporter"
|
|
11
13
|
require_relative "../rbs_extended/reporter"
|
|
14
|
+
require_relative "../rbs_extended/conformance_checker"
|
|
12
15
|
require_relative "../reflection"
|
|
13
16
|
require_relative "../type/combinator"
|
|
14
17
|
require_relative "../inference/coverage_scanner"
|
|
@@ -18,6 +21,10 @@ require_relative "../inference/project_patched_scanner"
|
|
|
18
21
|
require_relative "../inference/method_dispatcher/file_folding"
|
|
19
22
|
require_relative "buffer_binding"
|
|
20
23
|
require_relative "check_rules"
|
|
24
|
+
require_relative "dependency_recorder"
|
|
25
|
+
require_relative "self_call_resolution_recorder"
|
|
26
|
+
require_relative "incremental"
|
|
27
|
+
require_relative "incremental_session"
|
|
21
28
|
require_relative "dependency_source_inference"
|
|
22
29
|
require_relative "diagnostic"
|
|
23
30
|
require_relative "erb_template_detector"
|
|
@@ -25,6 +32,10 @@ require_relative "project_scan"
|
|
|
25
32
|
require_relative "result"
|
|
26
33
|
require_relative "run_stats"
|
|
27
34
|
require_relative "worker_session"
|
|
35
|
+
require_relative "runner/run_snapshots"
|
|
36
|
+
require_relative "runner/project_pre_passes"
|
|
37
|
+
require_relative "runner/pool_coordinator"
|
|
38
|
+
require_relative "runner/diagnostic_aggregator"
|
|
28
39
|
|
|
29
40
|
module Rigor
|
|
30
41
|
module Analysis
|
|
@@ -33,7 +44,8 @@ module Rigor
|
|
|
33
44
|
DEFAULT_CACHE_ROOT = ".rigor/cache"
|
|
34
45
|
|
|
35
46
|
attr_reader :cache_store, :plugin_registry, :dependency_source_index,
|
|
36
|
-
:rbs_extended_reporter, :boundary_cross_reporter
|
|
47
|
+
:rbs_extended_reporter, :boundary_cross_reporter, :file_dependencies,
|
|
48
|
+
:analyzed_files, :unresolved_self_calls
|
|
37
49
|
|
|
38
50
|
# @param configuration [Rigor::Configuration]
|
|
39
51
|
# @param explain [Boolean] surface fail-soft fallback events
|
|
@@ -82,10 +94,11 @@ module Rigor
|
|
|
82
94
|
# (bundler / lockfile / collection discovery, RbsLoader
|
|
83
95
|
# construction). Pool mode ignores the override — each
|
|
84
96
|
# worker continues to build its own Environment.
|
|
85
|
-
def initialize(configuration:, explain: false, # rubocop:disable Metrics/ParameterLists
|
|
97
|
+
def initialize(configuration:, explain: false, # rubocop:disable Metrics/ParameterLists,Metrics/AbcSize,Metrics/MethodLength
|
|
86
98
|
cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
|
|
87
99
|
plugin_requirer: nil, workers: 0, collect_stats: true,
|
|
88
|
-
buffer: nil, prebuilt: nil, environment: nil
|
|
100
|
+
buffer: nil, prebuilt: nil, environment: nil,
|
|
101
|
+
record_dependencies: false, record_self_calls: false, analyze_only: nil)
|
|
89
102
|
@configuration = configuration
|
|
90
103
|
@explain = explain
|
|
91
104
|
@cache_store = enforce_read_only_cache(cache_store, buffer)
|
|
@@ -95,6 +108,35 @@ module Rigor
|
|
|
95
108
|
@buffer = buffer
|
|
96
109
|
@prebuilt = prebuilt
|
|
97
110
|
@environment_override = environment
|
|
111
|
+
# ADR-46 slice 1 — opt-in cross-file dependency recording. Off by
|
|
112
|
+
# default; when true, `analyze_file` records each file's
|
|
113
|
+
# cross-file reads into `file_dependencies` (the incremental
|
|
114
|
+
# cache, a later slice, consumes them).
|
|
115
|
+
@record_dependencies = record_dependencies
|
|
116
|
+
# ADR-24 slice 4a — opt-in unresolved-implicit-self-call recording.
|
|
117
|
+
# Off by default; when true, `analyze_file` activates the engine
|
|
118
|
+
# choke-point recorder and collects each file's misses into
|
|
119
|
+
# `unresolved_self_calls` (a later closed-class-gated rule consumes
|
|
120
|
+
# them). Purely observational — diagnostics are byte-identical.
|
|
121
|
+
@record_self_calls = record_self_calls
|
|
122
|
+
@unresolved_self_calls = {}
|
|
123
|
+
# Memoised activation decision for the `call.self-undefined-method`
|
|
124
|
+
# rule (nil = not yet computed). See `self_undefined_rule_active?`.
|
|
125
|
+
@self_undefined_rule_active = nil
|
|
126
|
+
@analyzed_files = [].freeze
|
|
127
|
+
# In-memory source map for `#run_source` — `{ logical_path => source
|
|
128
|
+
# String }`. When set, `parse_source` reads bytes from here instead
|
|
129
|
+
# of disk and `expand_paths` accepts the (possibly non-existent)
|
|
130
|
+
# logical path. nil on a normal disk-backed run.
|
|
131
|
+
@in_memory_sources = nil
|
|
132
|
+
# ADR-46 slice 2 — the subset-analysis hook. When set (a collection
|
|
133
|
+
# of paths), the whole-project pre-pass still runs over every file
|
|
134
|
+
# (so the cross-file index is complete), but only files in this set
|
|
135
|
+
# are analyzed for diagnostics — the body tier re-analyses the
|
|
136
|
+
# affected closure and serves the rest from the per-file cache.
|
|
137
|
+
# `nil` (the default) analyzes everything.
|
|
138
|
+
@analyze_only = analyze_only && Set.new(analyze_only)
|
|
139
|
+
@file_dependencies = {}
|
|
98
140
|
@plugin_registry = Plugin::Registry::EMPTY
|
|
99
141
|
@dependency_source_index = DependencySourceInference::Index::EMPTY
|
|
100
142
|
@rbs_extended_reporter = RbsExtended::Reporter.new
|
|
@@ -103,18 +145,24 @@ module Rigor
|
|
|
103
145
|
# `#run` resets these for each invocation; pre-seed them to
|
|
104
146
|
# empty containers so `build_run_stats` / `pre_file_diagnostics`
|
|
105
147
|
# (private, called only from `#run`) can read them without
|
|
106
|
-
# nil-guards.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
148
|
+
# nil-guards. The four end-of-pass snapshots (RBS class /
|
|
149
|
+
# signature-path tables, synthesized-namespace names,
|
|
150
|
+
# `rigor:v1:conforms-to` results) live in one shared mutable
|
|
151
|
+
# {RunSnapshots} sink so the analysis path that writes them and
|
|
152
|
+
# the run / aggregator code that reads them stay in separate
|
|
153
|
+
# collaborators without a back-reference cycle.
|
|
154
|
+
@snapshots = RunSnapshots.new
|
|
110
155
|
@cached_plugin_prepare_diagnostics = [].freeze
|
|
111
156
|
@project_discovered_classes = {}.freeze
|
|
112
157
|
@project_discovered_def_nodes = {}.freeze
|
|
113
158
|
@project_discovered_def_sources = {}.freeze
|
|
114
159
|
@project_discovered_superclasses = {}.freeze
|
|
115
160
|
@project_discovered_includes = {}.freeze
|
|
161
|
+
@project_discovered_class_sources = {}.freeze
|
|
116
162
|
@project_discovered_method_visibilities = {}.freeze
|
|
117
163
|
@project_discovered_methods = {}.freeze
|
|
164
|
+
@project_data_member_layouts = {}.freeze
|
|
165
|
+
build_collaborators
|
|
118
166
|
end
|
|
119
167
|
|
|
120
168
|
# ADR-pending editor mode — present when the runner is wired
|
|
@@ -141,9 +189,7 @@ module Rigor
|
|
|
141
189
|
return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
|
|
142
190
|
|
|
143
191
|
expansion = expand_paths(paths)
|
|
144
|
-
@
|
|
145
|
-
@signature_paths_snapshot = []
|
|
146
|
-
@synthesized_namespaces_snapshot = []
|
|
192
|
+
@snapshots.reset_for_run
|
|
147
193
|
|
|
148
194
|
if @prebuilt
|
|
149
195
|
adopt_prebuilt_project_scan(@prebuilt)
|
|
@@ -151,19 +197,212 @@ module Rigor
|
|
|
151
197
|
run_project_pre_passes(expansion: expansion)
|
|
152
198
|
end
|
|
153
199
|
|
|
154
|
-
diagnostics =
|
|
155
|
-
diagnostics += analyze_files(target_files(expansion))
|
|
156
|
-
diagnostics += rbs_synthesized_namespace_diagnostics
|
|
157
|
-
diagnostics += rbs_extended_reporter_diagnostics
|
|
158
|
-
diagnostics += boundary_cross_diagnostics
|
|
159
|
-
diagnostics += source_rbs_synthesis_diagnostics
|
|
200
|
+
diagnostics = compute_run_diagnostics(expansion)
|
|
160
201
|
|
|
161
202
|
Result.new(
|
|
162
|
-
diagnostics: apply_severity_profile(diagnostics),
|
|
163
|
-
stats:
|
|
203
|
+
diagnostics: @diagnostic_aggregator.apply_severity_profile(diagnostics),
|
|
204
|
+
stats: stats_for_run(wall_started_at: wall_started_at, expansion: expansion)
|
|
164
205
|
)
|
|
165
206
|
end
|
|
166
207
|
|
|
208
|
+
# Analyze a single source String in memory, without writing it to
|
|
209
|
+
# disk — a clean entry point for embedders (LSP / editor mode) and a
|
|
210
|
+
# faster spec path than the per-call tmpdir + chdir. The source is
|
|
211
|
+
# bound to `path` (purely a logical identity carried in diagnostic
|
|
212
|
+
# locations; it need not exist on disk). The full run machinery still
|
|
213
|
+
# runs — environment build, plugin `prepare`, severity profile — so
|
|
214
|
+
# the result matches a one-file disk run; only the cross-file project
|
|
215
|
+
# pre-pass is empty (there is one file, and the per-file indexer
|
|
216
|
+
# self-discovers its own classes / defs).
|
|
217
|
+
#
|
|
218
|
+
# @param source [String] Ruby source to analyze.
|
|
219
|
+
# @param path [String] logical path for diagnostic locations.
|
|
220
|
+
# @return [Result]
|
|
221
|
+
def run_source(source:, path: "(source).rb")
|
|
222
|
+
@in_memory_sources = { path => source }
|
|
223
|
+
run([path])
|
|
224
|
+
ensure
|
|
225
|
+
@in_memory_sources = nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# ADR-46 — the project file set that a run over `paths` would
|
|
229
|
+
# analyze, computed by globbing only (no RBS environment build), so
|
|
230
|
+
# the incremental fingerprint can be derived cheaply on the warm path
|
|
231
|
+
# before deciding whether to build the env at all.
|
|
232
|
+
def analysis_file_set(paths = @configuration.paths)
|
|
233
|
+
expand_paths(paths).fetch(:files)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# ADR-46 §2 — inverts {#file_dependencies} into the reverse edge the
|
|
237
|
+
# incremental step walks: `dependents[X] = { A : A read a
|
|
238
|
+
# declaration / body from X }`. On an edit to X, the body tier
|
|
239
|
+
# (slice 2) re-analyses `{X} ∪ dependents[X]` and serves every other
|
|
240
|
+
# file from the per-file cache. Built on demand from the recorded
|
|
241
|
+
# `sources` sets (so it reflects whatever `analyze_file` captured —
|
|
242
|
+
# empty unless the runner was constructed with
|
|
243
|
+
# `record_dependencies: true`). The negative (`missing`) edges are
|
|
244
|
+
# NOT inverted here: they feed the structural tier (slice 3), which
|
|
245
|
+
# re-checks a consumer when a name it looked up and did not resolve
|
|
246
|
+
# later appears.
|
|
247
|
+
def file_dependents
|
|
248
|
+
Incremental.invert(@file_dependencies.transform_values(&:sources))
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# ADR-46 slice 4 — per-symbol body fingerprints, computed from the
|
|
252
|
+
# project pre-pass def index. Returns a frozen hash of the form:
|
|
253
|
+
# { "path/to/file.rb" => { "ClassName#method" => sha256_hex, … }, … }
|
|
254
|
+
# Used by {Analysis::IncrementalSession} to detect which symbols in a
|
|
255
|
+
# changed file actually changed bodies, so only callers of those
|
|
256
|
+
# specific symbols are re-checked. Only meaningful after a run that
|
|
257
|
+
# populated `@project_discovered_def_nodes` (i.e. any full or subset
|
|
258
|
+
# analysis); returns an empty frozen hash before the first run.
|
|
259
|
+
def symbol_fingerprints
|
|
260
|
+
result = Hash.new { |h, k| h[k] = {} }
|
|
261
|
+
@project_discovered_def_sources.each do |class_name, methods|
|
|
262
|
+
methods.each do |method_sym, path_line|
|
|
263
|
+
path = path_line.split(":", 2).first
|
|
264
|
+
node = @project_discovered_def_nodes.dig(class_name, method_sym)
|
|
265
|
+
next unless node
|
|
266
|
+
|
|
267
|
+
result[path]["#{class_name}##{method_sym}"] =
|
|
268
|
+
Digest::SHA256.hexdigest(node.location.slice)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
result.transform_values(&:freeze).freeze
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# ADR-46 slice 3 — per-file set of the qualified class/module names
|
|
275
|
+
# declared in that file. Used to detect a class that *appeared* in an
|
|
276
|
+
# edit so a subclass whose ancestor was previously undefined (and so
|
|
277
|
+
# recorded a negative class edge) is re-checked. Inverts the project
|
|
278
|
+
# class-source attribution (class → declaring files).
|
|
279
|
+
def class_declarations
|
|
280
|
+
result = Hash.new { |hash, key| hash[key] = Set.new }
|
|
281
|
+
@project_discovered_class_sources.each do |class_name, files|
|
|
282
|
+
files.each { |file| result[file] << class_name }
|
|
283
|
+
end
|
|
284
|
+
result.transform_values(&:freeze).freeze
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# ADR-45 — unchanged-project fast path. Serves the whole run's
|
|
288
|
+
# (pre-severity-profile) diagnostics from one record-and-validate
|
|
289
|
+
# cache entry when every file the previous run read is unchanged,
|
|
290
|
+
# skipping the dominant per-file inference. The dependency set is
|
|
291
|
+
# collected AFTER the run (so it captures files the plugins read
|
|
292
|
+
# mid-analysis, e.g. a Pundit policy) and re-validated on the next
|
|
293
|
+
# run; the entry is keyed on the inputs known up front (config, gem
|
|
294
|
+
# / engine versions, analyzed-path set).
|
|
295
|
+
def compute_run_diagnostics(expansion)
|
|
296
|
+
@run_served_from_cache = false
|
|
297
|
+
return assemble_run_diagnostics(expansion) unless run_result_cacheable?
|
|
298
|
+
|
|
299
|
+
environment = @pool_coordinator.resolve_sequential_environment(source_files: target_files(expansion))
|
|
300
|
+
rbs_descriptor = environment&.rbs_loader ? Cache::RbsDescriptor.build(environment.rbs_loader) : Cache::Descriptor.new
|
|
301
|
+
key_descriptor = run_key_descriptor(expansion, rbs_descriptor)
|
|
302
|
+
return assemble_run_diagnostics(expansion, environment: environment) if key_descriptor.nil?
|
|
303
|
+
|
|
304
|
+
computed = false
|
|
305
|
+
diagnostics = @cache_store.fetch_or_validate(
|
|
306
|
+
producer_id: "analysis.run-diagnostics", key_descriptor: key_descriptor
|
|
307
|
+
) do
|
|
308
|
+
computed = true
|
|
309
|
+
diags = assemble_run_diagnostics(expansion, environment: environment)
|
|
310
|
+
[diags, run_dependency_descriptor(expansion, rbs_descriptor)]
|
|
311
|
+
end
|
|
312
|
+
@run_served_from_cache = !computed
|
|
313
|
+
diagnostics
|
|
314
|
+
rescue StandardError
|
|
315
|
+
# The result cache must never break a run. If anything in the
|
|
316
|
+
# cache path fails, fall back to a direct, uncached analysis.
|
|
317
|
+
@run_served_from_cache = false
|
|
318
|
+
assemble_run_diagnostics(expansion)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def assemble_run_diagnostics(expansion, environment: nil)
|
|
322
|
+
diagnostics = @diagnostic_aggregator.pre_file_diagnostics(expansion)
|
|
323
|
+
# ADR-46 — record which project files this run actually analyzed
|
|
324
|
+
# (the `analyze_only` subset, or all of them). The incremental
|
|
325
|
+
# orchestrator serves every analyzed-but-not-affected file from the
|
|
326
|
+
# per-file cache, so it needs the full analyzed set to subtract the
|
|
327
|
+
# affected closure from.
|
|
328
|
+
targets = target_files(expansion)
|
|
329
|
+
@analyzed_files = targets
|
|
330
|
+
diagnostics += @pool_coordinator.analyze_files(targets, environment: environment)
|
|
331
|
+
diagnostics += @diagnostic_aggregator.rbs_synthesized_namespace_diagnostics
|
|
332
|
+
diagnostics += @diagnostic_aggregator.conforms_to_diagnostics
|
|
333
|
+
diagnostics += @diagnostic_aggregator.rbs_extended_reporter_diagnostics
|
|
334
|
+
diagnostics += @diagnostic_aggregator.boundary_cross_diagnostics
|
|
335
|
+
diagnostics + @diagnostic_aggregator.source_rbs_synthesis_diagnostics
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# A cache hit skipped the analysis, so the per-run stats (wall
|
|
339
|
+
# split, RBS-class counts, …) were never gathered — report none
|
|
340
|
+
# rather than the stale snapshot defaults.
|
|
341
|
+
def stats_for_run(wall_started_at:, expansion:)
|
|
342
|
+
return nil unless @collect_stats
|
|
343
|
+
return nil if @run_served_from_cache
|
|
344
|
+
|
|
345
|
+
build_run_stats(wall_started_at: wall_started_at, expansion: expansion)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Cacheable only for a full sequential project run with a writable
|
|
349
|
+
# cache and no per-buffer / prebuilt override — every other mode has
|
|
350
|
+
# a different result identity (pool workers read in separate
|
|
351
|
+
# processes; editor mode is per-buffer; prebuilt is the LSP path).
|
|
352
|
+
def run_result_cacheable?
|
|
353
|
+
!@cache_store.nil? && !@cache_store.read_only? &&
|
|
354
|
+
@buffer.nil? && @prebuilt.nil? && !pool_mode?
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Stable cache key inputs — known before the run: a digest of the
|
|
358
|
+
# resolved configuration, the engine + rbs versions + `--explain`,
|
|
359
|
+
# and the analyzed-path SET (adding/removing a file changes the
|
|
360
|
+
# key; editing one is caught by dependency validation). nil disables
|
|
361
|
+
# the cache for this run rather than risking a malformed key.
|
|
362
|
+
def run_key_descriptor(expansion, rbs_descriptor)
|
|
363
|
+
Cache::Descriptor.new(
|
|
364
|
+
gems: rbs_descriptor.gems,
|
|
365
|
+
configs: rbs_descriptor.configs + [
|
|
366
|
+
config_hash_entry("configuration", Marshal.dump(@configuration.to_h)),
|
|
367
|
+
config_hash_entry("engine", "#{Rigor::VERSION}:#{Cache::Descriptor::SCHEMA_VERSION}:#{@explain}"),
|
|
368
|
+
config_hash_entry("paths", expansion.fetch(:files).sort.join("\n"))
|
|
369
|
+
]
|
|
370
|
+
)
|
|
371
|
+
rescue StandardError
|
|
372
|
+
nil
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Files the run actually depended on, collected AFTER it ran:
|
|
376
|
+
# every analyzed file, every RBS `sig` file (`rbs_descriptor.files`),
|
|
377
|
+
# and every file each plugin read (complete post-run, so reads made
|
|
378
|
+
# mid-analysis are included). Re-digested on the next run by
|
|
379
|
+
# {Descriptor#fresh?}.
|
|
380
|
+
def run_dependency_descriptor(expansion, rbs_descriptor)
|
|
381
|
+
entries = analyzed_file_entries(expansion) + rbs_descriptor.files
|
|
382
|
+
@plugin_registry.plugins.each do |plugin|
|
|
383
|
+
# Read the boundary WITHOUT triggering its lazy `@io_boundary ||=`
|
|
384
|
+
# initializer: plugin instances are frozen after the run, and a
|
|
385
|
+
# plugin that never built a boundary read no files through it, so
|
|
386
|
+
# it contributes no dependencies.
|
|
387
|
+
boundary = plugin.instance_variable_get(:@io_boundary)
|
|
388
|
+
entries.concat(boundary.cache_descriptor.files) if boundary
|
|
389
|
+
end
|
|
390
|
+
Cache::Descriptor.new(files: entries)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def analyzed_file_entries(expansion)
|
|
394
|
+
expansion.fetch(:files).map do |path|
|
|
395
|
+
physical = @buffer ? @buffer.resolve(path) : path
|
|
396
|
+
Cache::Descriptor::FileEntry.new(
|
|
397
|
+
path: physical, comparator: :digest, value: Digest::SHA256.file(physical).hexdigest
|
|
398
|
+
)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def config_hash_entry(key, payload)
|
|
403
|
+
Cache::Descriptor::ConfigEntry.new(key: key, value_hash: Digest::SHA256.hexdigest(payload))
|
|
404
|
+
end
|
|
405
|
+
|
|
167
406
|
# Runs every project-wide pre-pass (`load_plugins` +
|
|
168
407
|
# `plugin#prepare` + dependency-source builder +
|
|
169
408
|
# synthetic-method scanner + project-patched scanner)
|
|
@@ -188,86 +427,18 @@ module Rigor
|
|
|
188
427
|
# already populated for subsequent dispatch use.
|
|
189
428
|
def prepare_project_scan(paths: @configuration.paths)
|
|
190
429
|
expansion = expand_paths(paths)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
dependency_source_index: @dependency_source_index,
|
|
195
|
-
synthetic_method_index: @synthetic_method_index,
|
|
196
|
-
project_patched_methods: @project_patched_methods,
|
|
197
|
-
plugin_prepare_diagnostics: @cached_plugin_prepare_diagnostics.dup.freeze,
|
|
198
|
-
pre_eval_diagnostics: @pre_eval_diagnostics_from_scanner.dup.freeze
|
|
199
|
-
)
|
|
430
|
+
result = @pre_passes.run(expansion: expansion)
|
|
431
|
+
apply_pre_passes_result(result)
|
|
432
|
+
@pre_passes.build_project_scan(result)
|
|
200
433
|
end
|
|
201
434
|
|
|
202
|
-
# Internal: drives every project-wide pre-pass
|
|
203
|
-
#
|
|
204
|
-
#
|
|
205
|
-
# `#
|
|
206
|
-
#
|
|
435
|
+
# Internal: drives every project-wide pre-pass through the
|
|
436
|
+
# {ProjectPrePasses} collaborator and adopts the resulting
|
|
437
|
+
# state onto the runner's ivar surface in the order the
|
|
438
|
+
# downstream `#run` body expects. Shared by `#prepare_project_scan`
|
|
439
|
+
# and the prebuilt-less `#run` path.
|
|
207
440
|
def run_project_pre_passes(expansion:)
|
|
208
|
-
@
|
|
209
|
-
@dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
|
|
210
|
-
# ADR-18 slice 3 — plugin prepare MUST run before the
|
|
211
|
-
# synthetic-method scanner so cross-plugin facts
|
|
212
|
-
# (`:dry_type_aliases` etc.) are already published when
|
|
213
|
-
# the scanner resolves Tier C `returns_from_arg:`
|
|
214
|
-
# lookups. The diagnostics produced by prepare are
|
|
215
|
-
# captured here so `pre_file_diagnostics` can re-emit
|
|
216
|
-
# them in the existing order without invoking prepare
|
|
217
|
-
# twice. Pool mode still re-runs prepare per worker
|
|
218
|
-
# (workers don't see this early invocation), preserving
|
|
219
|
-
# the existing Phase 4b contract.
|
|
220
|
-
@cached_plugin_prepare_diagnostics =
|
|
221
|
-
pool_mode? ? [] : plugin_prepare_diagnostics
|
|
222
|
-
# ADR-16 slice 2b — Tier C pre-pass. Built once per run
|
|
223
|
-
# against the resolved file set + the loaded plugin
|
|
224
|
-
# registry's `heredoc_templates` so synthetic methods are
|
|
225
|
-
# visible cross-file when per-file inference dispatches.
|
|
226
|
-
@synthetic_method_index = Inference::SyntheticMethodScanner.scan(
|
|
227
|
-
plugin_registry: @plugin_registry,
|
|
228
|
-
paths: expansion.fetch(:files),
|
|
229
|
-
environment: nil,
|
|
230
|
-
fact_store: shared_fact_store,
|
|
231
|
-
buffer: @buffer
|
|
232
|
-
)
|
|
233
|
-
# ADR-17 slice 2 — pre-eval pre-pass. Built once per run
|
|
234
|
-
# from the `pre_eval:` entries that exist on disk
|
|
235
|
-
# (slice-1's `pre-eval.file-not-found` `:error` already
|
|
236
|
-
# surfaced any missing entries; the scanner skips them
|
|
237
|
-
# here). The resulting {ProjectPatchedMethods} registry
|
|
238
|
-
# is consulted by the dispatcher tier between plugins
|
|
239
|
-
# and dependency-source inference so project-side
|
|
240
|
-
# patches resolve cross-file.
|
|
241
|
-
existing_pre_eval = @configuration.pre_eval.select { |path| File.file?(path) }
|
|
242
|
-
pre_eval_outcome = Inference::ProjectPatchedScanner.scan(existing_pre_eval, buffer: @buffer)
|
|
243
|
-
@project_patched_methods = pre_eval_outcome.registry
|
|
244
|
-
@pre_eval_diagnostics_from_scanner = pre_eval_outcome.diagnostics
|
|
245
|
-
# Cross-file class discovery — walks every project file
|
|
246
|
-
# for `class Foo` / `module Bar` declarations so a
|
|
247
|
-
# `Foo.method_call` receiver in one file resolves a
|
|
248
|
-
# `class Foo` declared in a sibling file. Without this
|
|
249
|
-
# pre-pass each file's `discovered_classes` was per-file
|
|
250
|
-
# only, and lexical lookup fell back to stdlib `::Foo`
|
|
251
|
-
# for any user class shadowing a stdlib name (e.g.
|
|
252
|
-
# `Google::Cloud::Storage::File`). Cost is one extra
|
|
253
|
-
# parse pass over the project; small projects pay
|
|
254
|
-
# tens of ms, larger projects ~1s. Future optimisation
|
|
255
|
-
# can share parses with the existing scanner passes.
|
|
256
|
-
@project_discovered_classes =
|
|
257
|
-
Inference::ScopeIndexer.discovered_classes_for_paths(expansion.fetch(:files), buffer: @buffer)
|
|
258
|
-
# ADR-24 slice 2 — cross-file def-node + class->superclass
|
|
259
|
-
# index so an implicit-self call inside a subclass
|
|
260
|
-
# resolves a superclass `def` declared in a sibling
|
|
261
|
-
# file. One extra parse pass over the project; shares
|
|
262
|
-
# the cost profile of the class-discovery pass above.
|
|
263
|
-
def_index =
|
|
264
|
-
Inference::ScopeIndexer.discovered_def_index_for_paths(expansion.fetch(:files), buffer: @buffer)
|
|
265
|
-
@project_discovered_def_nodes = def_index.fetch(:def_nodes)
|
|
266
|
-
@project_discovered_def_sources = def_index.fetch(:def_sources)
|
|
267
|
-
@project_discovered_superclasses = def_index.fetch(:superclasses)
|
|
268
|
-
@project_discovered_includes = def_index.fetch(:includes)
|
|
269
|
-
@project_discovered_method_visibilities = def_index.fetch(:method_visibilities)
|
|
270
|
-
@project_discovered_methods = def_index.fetch(:methods)
|
|
441
|
+
apply_pre_passes_result(@pre_passes.run(expansion: expansion))
|
|
271
442
|
end
|
|
272
443
|
|
|
273
444
|
# Internal: adopts a frozen {ProjectScan} snapshot supplied
|
|
@@ -275,156 +446,37 @@ module Rigor
|
|
|
275
446
|
# the runner's ivar surface, mirroring what
|
|
276
447
|
# `run_project_pre_passes` would have produced.
|
|
277
448
|
def adopt_prebuilt_project_scan(scan)
|
|
278
|
-
@
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
else
|
|
310
|
-
environment = resolve_sequential_environment(source_files: files)
|
|
311
|
-
# Snapshot the small synthesized-namespace name list (NOT the
|
|
312
|
-
# env — see the method comment) so #run can surface the
|
|
313
|
-
# malformed-RBS `:info` diagnostic without rebuilding the env.
|
|
314
|
-
# Gated on the project actually declaring `signature_paths:`:
|
|
315
|
-
# synthesis only matters for the project's own RBS, and
|
|
316
|
-
# `#synthesized_namespaces` forces the (otherwise-lazy) RBS env
|
|
317
|
-
# to build — doing so when there is no project sig set would
|
|
318
|
-
# warm `.rigor/cache` on a bare `--no-stats` run.
|
|
319
|
-
@synthesized_namespaces_snapshot =
|
|
320
|
-
project_signature_paths? ? (environment.rbs_loader&.synthesized_namespaces || []) : []
|
|
321
|
-
result = files.flat_map { |path| analyze_file(path, environment) }
|
|
322
|
-
if @collect_stats
|
|
323
|
-
loader = environment.rbs_loader
|
|
324
|
-
@class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
|
|
325
|
-
@signature_paths_snapshot = loader&.signature_paths || [].freeze
|
|
326
|
-
end
|
|
327
|
-
result
|
|
328
|
-
end
|
|
329
|
-
end
|
|
330
|
-
|
|
331
|
-
# Sequential-mode environment resolver. Returns the supplied
|
|
332
|
-
# `environment:` override (with the runner's fresh per-run
|
|
333
|
-
# reporter pair attached so dispatcher events route to THIS
|
|
334
|
-
# runner's diagnostics) when present; otherwise builds a
|
|
335
|
-
# fresh Environment per-call via {#build_runner_environment}
|
|
336
|
-
# — preserving the pre-override behaviour bit-for-bit.
|
|
337
|
-
def resolve_sequential_environment(source_files: [])
|
|
338
|
-
return build_runner_environment(source_files: source_files) unless @environment_override
|
|
339
|
-
|
|
340
|
-
@environment_override.attach_reporters!(
|
|
341
|
-
rbs_extended_reporter: @rbs_extended_reporter,
|
|
342
|
-
boundary_cross_reporter: @boundary_cross_reporter
|
|
343
|
-
)
|
|
344
|
-
@environment_override
|
|
345
|
-
end
|
|
346
|
-
private :resolve_sequential_environment
|
|
347
|
-
|
|
348
|
-
# Pre-file diagnostic streams that fire once per run rather
|
|
349
|
-
# than per analyzed file: plugin load / prepare envelopes,
|
|
350
|
-
# the ADR-10 dependency-source resolution surface, and the
|
|
351
|
-
# `expand_paths` errors for `paths:` entries that don't
|
|
352
|
-
# exist or aren't `.rb`. Aggregated here so `#run` stays
|
|
353
|
-
# under the ABC budget.
|
|
354
|
-
#
|
|
355
|
-
# ADR-15 Phase 4b — `plugin_prepare_diagnostics` runs on
|
|
356
|
-
# the coordinator's plugin registry under sequential mode;
|
|
357
|
-
# under pool mode each worker re-runs `prepare` against
|
|
358
|
-
# its own plugin instances, so the pool path drains the
|
|
359
|
-
# first worker's prepare-diagnostic snapshot into the
|
|
360
|
-
# aggregated diagnostic stream instead (see
|
|
361
|
-
# {#analyze_files_in_pool}). Skipping the coordinator
|
|
362
|
-
# prepare in pool mode avoids double-running `#prepare`
|
|
363
|
-
# against the coordinator-side plugin instances (which
|
|
364
|
-
# the pool path never consults for per-file analysis).
|
|
365
|
-
def pre_file_diagnostics(expansion)
|
|
366
|
-
# ADR-18 slice 3 — prepare diagnostics are captured
|
|
367
|
-
# earlier in #run (before the synthetic-method scanner)
|
|
368
|
-
# so cross-plugin facts are available to the scanner.
|
|
369
|
-
# We re-surface the captured diagnostics here so the
|
|
370
|
-
# existing pre_file_diagnostics ordering is preserved.
|
|
371
|
-
prepare = pool_mode? ? [] : @cached_plugin_prepare_diagnostics
|
|
372
|
-
plugin_load_diagnostics +
|
|
373
|
-
prepare +
|
|
374
|
-
pre_eval_diagnostics +
|
|
375
|
-
dependency_source_diagnostics +
|
|
376
|
-
dependency_source_budget_diagnostics +
|
|
377
|
-
dependency_source_config_conflict_diagnostics +
|
|
378
|
-
rbs_coverage_diagnostics +
|
|
379
|
-
expansion.fetch(:errors)
|
|
380
|
-
end
|
|
381
|
-
|
|
382
|
-
# Returns the per-run shared `Plugin::FactStore` instance.
|
|
383
|
-
# All loaded plugins share this store through their
|
|
384
|
-
# respective `Plugin::Services` (the same instance is
|
|
385
|
-
# threaded by `Plugin::Loader.load`). Returns `nil` when
|
|
386
|
-
# no plugins are loaded.
|
|
387
|
-
def shared_fact_store
|
|
388
|
-
return nil if @plugin_registry.nil? || @plugin_registry.empty?
|
|
389
|
-
|
|
390
|
-
@plugin_registry.plugins.first&.services&.fact_store
|
|
391
|
-
end
|
|
392
|
-
|
|
393
|
-
# ADR-17 slice 1 — surface a `:error` diagnostic for each
|
|
394
|
-
# `pre_eval:` entry whose resolved path doesn't exist on
|
|
395
|
-
# disk. Loud failure mode (`:error`, not `:warning`):
|
|
396
|
-
# a missing pre_eval path is a configuration mistake the
|
|
397
|
-
# user must fix before analysis is meaningful.
|
|
398
|
-
#
|
|
399
|
-
# Slice 2 adds the `:warning` `pre-eval.parse-error`
|
|
400
|
-
# stream from the pre-pass scanner — accumulated as
|
|
401
|
-
# `@pre_eval_diagnostics_from_scanner` during {#run} and
|
|
402
|
-
# merged here so both diagnostics flow through the same
|
|
403
|
-
# severity / ordering pipeline.
|
|
404
|
-
def pre_eval_diagnostics
|
|
405
|
-
not_found = @configuration.pre_eval.filter_map do |path|
|
|
406
|
-
next if File.file?(path)
|
|
407
|
-
|
|
408
|
-
Diagnostic.new(
|
|
409
|
-
path: ".rigor.yml", line: 1, column: 1,
|
|
410
|
-
message: "pre_eval entry not found: #{path.inspect}. " \
|
|
411
|
-
"Pre-evaluation requires the file to exist on disk; remove the entry " \
|
|
412
|
-
"or create the file before re-running analysis.",
|
|
413
|
-
severity: :error,
|
|
414
|
-
rule: "pre-eval.file-not-found",
|
|
415
|
-
source_family: :builtin
|
|
416
|
-
)
|
|
417
|
-
end
|
|
418
|
-
not_found + Array(@pre_eval_diagnostics_from_scanner).map { |hash| diagnostic_from_hash(hash) }
|
|
419
|
-
end
|
|
420
|
-
|
|
421
|
-
def diagnostic_from_hash(hash)
|
|
422
|
-
Diagnostic.new(
|
|
423
|
-
path: hash.fetch(:path), line: hash.fetch(:line), column: hash.fetch(:column),
|
|
424
|
-
message: hash.fetch(:message), severity: hash.fetch(:severity),
|
|
425
|
-
rule: hash.fetch(:rule), source_family: :builtin
|
|
426
|
-
)
|
|
427
|
-
end
|
|
449
|
+
apply_pre_passes_result(@pre_passes.adopt_prebuilt(scan))
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Internal: copies a {ProjectPrePasses::Result} bundle onto the
|
|
453
|
+
# runner's ivars in the assignment order the original inline
|
|
454
|
+
# pre-pass body used, so every downstream reader (per-file
|
|
455
|
+
# analysis seed, pool environment build, diagnostic aggregator)
|
|
456
|
+
# sees the same ivar surface. The prebuilt path leaves the
|
|
457
|
+
# discovery tables at their frozen-empty constructor defaults
|
|
458
|
+
# (the bundle carries `nil` for them, matching the original
|
|
459
|
+
# adopt path that never touched them).
|
|
460
|
+
def apply_pre_passes_result(result)
|
|
461
|
+
@plugin_registry = result.plugin_registry
|
|
462
|
+
@dependency_source_index = result.dependency_source_index
|
|
463
|
+
@cached_plugin_prepare_diagnostics = result.cached_plugin_prepare_diagnostics
|
|
464
|
+
@synthetic_method_index = result.synthetic_method_index
|
|
465
|
+
@project_patched_methods = result.project_patched_methods
|
|
466
|
+
@pre_eval_diagnostics_from_scanner = result.pre_eval_diagnostics_from_scanner
|
|
467
|
+
@project_discovered_classes = result.discovered_classes if result.discovered_classes
|
|
468
|
+
@project_discovered_def_nodes = result.discovered_def_nodes if result.discovered_def_nodes
|
|
469
|
+
@project_discovered_def_sources = result.discovered_def_sources if result.discovered_def_sources
|
|
470
|
+
@project_discovered_superclasses = result.discovered_superclasses if result.discovered_superclasses
|
|
471
|
+
@project_discovered_includes = result.discovered_includes if result.discovered_includes
|
|
472
|
+
@project_discovered_class_sources = result.discovered_class_sources if result.discovered_class_sources
|
|
473
|
+
if result.discovered_method_visibilities
|
|
474
|
+
@project_discovered_method_visibilities = result.discovered_method_visibilities
|
|
475
|
+
end
|
|
476
|
+
@project_discovered_methods = result.discovered_methods if result.discovered_methods
|
|
477
|
+
@project_data_member_layouts = result.data_member_layouts if result.data_member_layouts
|
|
478
|
+
end
|
|
479
|
+
private :run_project_pre_passes, :adopt_prebuilt_project_scan, :apply_pre_passes_result
|
|
428
480
|
|
|
429
481
|
# `target_ruby` flows through to Prism's `version:` option.
|
|
430
482
|
# Prism enforces the supported range and raises
|
|
@@ -463,6 +515,11 @@ module Rigor
|
|
|
463
515
|
# buffer".
|
|
464
516
|
def target_files(expansion)
|
|
465
517
|
files = expansion.fetch(:files)
|
|
518
|
+
# ADR-46 slice 2 — restrict the analyzed set to the affected
|
|
519
|
+
# closure while the pre-pass (run separately over `expansion`'s
|
|
520
|
+
# full file list) keeps the cross-file index complete. Buffer mode
|
|
521
|
+
# takes precedence — its single logical path is the analyzed set.
|
|
522
|
+
files = files.select { |path| @analyze_only.include?(path) } if @analyze_only
|
|
466
523
|
return files if @buffer.nil?
|
|
467
524
|
|
|
468
525
|
[@buffer.logical_path]
|
|
@@ -482,318 +539,57 @@ module Rigor
|
|
|
482
539
|
Cache::Store.new(root: cache_store.root, read_only: true)
|
|
483
540
|
end
|
|
484
541
|
|
|
485
|
-
#
|
|
486
|
-
#
|
|
487
|
-
#
|
|
488
|
-
#
|
|
489
|
-
#
|
|
490
|
-
#
|
|
491
|
-
#
|
|
492
|
-
#
|
|
493
|
-
#
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
# the Ruby Bug #22075 use-after-free and the worker-side
|
|
503
|
-
# `Ractor::IsolationError` that make the Ractor pool unusable
|
|
504
|
-
# (see the ADR-15 Amendment +
|
|
505
|
-
# docs/notes/20260520-ractor-pool-cruby-uaf.md). The Ractor pool
|
|
506
|
-
# is preserved but off the default path — `RIGOR_POOL_BACKEND=ractor`
|
|
507
|
-
# opts back in so it stays testable. Platforms without `fork`
|
|
508
|
-
# (Windows) fall back to sequential.
|
|
509
|
-
def pool_backend
|
|
510
|
-
return :ractor if ENV["RIGOR_POOL_BACKEND"] == "ractor"
|
|
511
|
-
return :fork if Process.respond_to?(:fork)
|
|
512
|
-
|
|
513
|
-
:sequential
|
|
514
|
-
end
|
|
515
|
-
|
|
516
|
-
# Routes pool-mode analysis to the selected backend.
|
|
517
|
-
def dispatch_pool(files)
|
|
518
|
-
case pool_backend
|
|
519
|
-
when :ractor then analyze_files_in_pool(files)
|
|
520
|
-
when :fork then analyze_files_in_fork_pool(files)
|
|
521
|
-
else
|
|
522
|
-
analyze_files_sequentially_fallback(
|
|
523
|
-
files, reason: "fork-based parallelism is unavailable on this platform"
|
|
524
|
-
)
|
|
525
|
-
end
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
# Coordinator-side Environment used by the sequential code
|
|
529
|
-
# path. Pool mode builds one Environment per worker inside
|
|
530
|
-
# the worker Ractor's body instead.
|
|
531
|
-
#
|
|
532
|
-
# ADR-32 WD4 — `source_files:` is threaded down so that
|
|
533
|
-
# `Environment.for_project` can invoke each loaded plugin's
|
|
534
|
-
# `source_rbs_synthesizer` callable per project source file
|
|
535
|
-
# at env-build time. Defaults to `[]` for callers that don't
|
|
536
|
-
# have a file list yet (e.g. pre-pass-only build paths); in
|
|
537
|
-
# that case no synthesised RBS is contributed.
|
|
538
|
-
def build_runner_environment(source_files: [])
|
|
539
|
-
Environment.for_project(
|
|
540
|
-
libraries: @configuration.libraries,
|
|
541
|
-
signature_paths: @configuration.signature_paths,
|
|
542
|
-
cache_store: @cache_store,
|
|
543
|
-
plugin_registry: @plugin_registry,
|
|
544
|
-
dependency_source_index: @dependency_source_index,
|
|
542
|
+
# Wires the three responsibility collaborators. Called at the end
|
|
543
|
+
# of construction (after every state ivar is seeded). The per-run
|
|
544
|
+
# varying state (the plugin registry, dependency-source / scanner
|
|
545
|
+
# indexes, prepare-diagnostic snapshot, and the four end-of-pass
|
|
546
|
+
# snapshots) is reached through reader procs so each collaborator
|
|
547
|
+
# observes the live ivar value at call time without a
|
|
548
|
+
# back-reference cycle. The reporter accumulators and the
|
|
549
|
+
# {RunSnapshots} sink are shared mutable instances.
|
|
550
|
+
def build_collaborators # rubocop:disable Metrics/MethodLength
|
|
551
|
+
@pre_passes = ProjectPrePasses.new(
|
|
552
|
+
configuration: @configuration, cache_store: @cache_store, buffer: @buffer,
|
|
553
|
+
plugin_requirer: @plugin_requirer, pool_mode: -> { pool_mode? }
|
|
554
|
+
)
|
|
555
|
+
@pool_coordinator = PoolCoordinator.new(
|
|
556
|
+
configuration: @configuration, cache_store: @cache_store, explain: @explain,
|
|
557
|
+
workers: @workers, collect_stats: @collect_stats, buffer: @buffer,
|
|
558
|
+
environment_override: @environment_override,
|
|
545
559
|
rbs_extended_reporter: @rbs_extended_reporter,
|
|
546
560
|
boundary_cross_reporter: @boundary_cross_reporter,
|
|
547
561
|
source_rbs_synthesis_reporter: @source_rbs_synthesis_reporter,
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
source_files: source_files
|
|
562
|
+
snapshots: @snapshots,
|
|
563
|
+
plugin_registry: -> { @plugin_registry },
|
|
564
|
+
dependency_source_index: -> { @dependency_source_index },
|
|
565
|
+
synthetic_method_index: -> { @synthetic_method_index },
|
|
566
|
+
project_patched_methods: -> { @project_patched_methods },
|
|
567
|
+
project_scope_seed: -> { project_scope_seed_tables },
|
|
568
|
+
analyze_file: ->(path, environment) { analyze_file(path, environment) }
|
|
556
569
|
)
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
# ADR-15 Phase 4b — Ractor pool around {WorkerSession}.
|
|
560
|
-
# Spawns `@workers` Ractors; each takes the shareable
|
|
561
|
-
# payload (Configuration, cache_root String, plugin
|
|
562
|
-
# Blueprint Array, explain Boolean) and builds its OWN
|
|
563
|
-
# WorkerSession internally. Files are distributed
|
|
564
|
-
# round-robin across the pool; each worker writes back to
|
|
565
|
-
# the main Ractor's mailbox via `Ractor.main.send` with
|
|
566
|
-
# one of three message kinds:
|
|
567
|
-
#
|
|
568
|
-
# - `[:prepare, diagnostics]` — once at startup, the
|
|
569
|
-
# session's `prepare_diagnostics` snapshot. The
|
|
570
|
-
# coordinator keeps the FIRST worker's snapshot only
|
|
571
|
-
# (plugin `#prepare` is deterministic per plugin, so
|
|
572
|
-
# each worker produces the same diagnostic set; surfacing
|
|
573
|
-
# them once avoids N× duplication).
|
|
574
|
-
# - `[:file, path, diagnostics]` — one per analysed file.
|
|
575
|
-
# - `[:done, drained_reporters]` — once at exit, the
|
|
576
|
-
# per-worker reporter snapshots for end-of-pool merge.
|
|
577
|
-
#
|
|
578
|
-
# The Ruby 4.0+ Ractor model uses a single per-Ractor
|
|
579
|
-
# mailbox (no `Ractor.yield`); workers push back via
|
|
580
|
-
# `Ractor.main.send`. The coordinator drains its mailbox
|
|
581
|
-
# via `Ractor.receive` until it has counted exactly
|
|
582
|
-
# `pool.size` `:done` messages.
|
|
583
|
-
#
|
|
584
|
-
# Diagnostic order: original path order. Workers may
|
|
585
|
-
# complete files out of order; the coordinator re-orders
|
|
586
|
-
# via the `results_by_path` Hash before flattening.
|
|
587
|
-
#
|
|
588
|
-
# Reporter merge: per-worker `RbsExtended::Reporter` and
|
|
589
|
-
# `BoundaryCrossReporter` entries are replayed into the
|
|
590
|
-
# runner-side accumulators via their `record_*` APIs,
|
|
591
|
-
# which dedupe on the same keys as a single-session run
|
|
592
|
-
# would. Net result: reporter state is identical to the
|
|
593
|
-
# sequential path.
|
|
594
|
-
def analyze_files_in_pool(files) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
595
|
-
# Pre-warm class-level lazy memos on the MAIN Ractor.
|
|
596
|
-
# `Environment::ClassRegistry.default` is the
|
|
597
|
-
# default kwarg threaded through `Environment.new`
|
|
598
|
-
# inside each worker session; lazy-initialising it
|
|
599
|
-
# from a non-main Ractor would trip
|
|
600
|
-
# `Ractor::IsolationError`. Touching it here forces
|
|
601
|
-
# the (shareable) registry into the class-ivar cache
|
|
602
|
-
# before any worker reads.
|
|
603
|
-
Environment::ClassRegistry.default
|
|
604
|
-
|
|
605
|
-
# ADR-15 Phase 4b.x — pre-warm the RBS cache so
|
|
606
|
-
# workers serve every reflection query from the
|
|
607
|
-
# Marshal blob on disk. Without this, the first
|
|
608
|
-
# cache MISS inside a worker falls through to
|
|
609
|
-
# `RBS::EnvironmentLoader.new`, which reads a chain
|
|
610
|
-
# of non-`Ractor.shareable?` RubyGems / RBS module
|
|
611
|
-
# constants and raises `Ractor::IsolationError`.
|
|
612
|
-
# Pre-warming requires a `cache_store`; the run aborts
|
|
613
|
-
# to sequential mode otherwise. See ADR-15 Phase 4b.x
|
|
614
|
-
# for the full chain of failing constants.
|
|
615
|
-
if @cache_store.nil?
|
|
616
|
-
return analyze_files_sequentially_fallback(
|
|
617
|
-
files, reason: "pool mode requires a cache_store (--no-cache disables pool)"
|
|
618
|
-
)
|
|
619
|
-
end
|
|
620
|
-
prewarm_rbs_cache_for_pool
|
|
621
|
-
|
|
622
|
-
configuration = @configuration
|
|
623
|
-
cache_root = @cache_store&.root
|
|
624
|
-
blueprints = @plugin_registry.blueprints
|
|
625
|
-
explain = @explain
|
|
626
|
-
# ADR-32 WD4 — the full project file list travels into
|
|
627
|
-
# every Ractor worker so each worker's WorkerSession
|
|
628
|
-
# can invoke loaded plugins' source_rbs_synthesizers at
|
|
629
|
-
# env-build time. The list is a frozen Array<String>;
|
|
630
|
-
# cheaply shareable.
|
|
631
|
-
shareable_source_files = files.map { |path| path.to_s.dup.freeze }.freeze
|
|
632
|
-
|
|
633
|
-
pool = Array.new(@workers) do
|
|
634
|
-
Ractor.new(configuration, cache_root, blueprints, explain, shareable_source_files) do |configuration, cache_root, blueprints, explain, shareable_source_files| # rubocop:disable Layout/LineLength
|
|
635
|
-
cache_store = cache_root ? Rigor::Cache::Store.new(root: cache_root) : nil
|
|
636
|
-
session = Rigor::Analysis::WorkerSession.new(
|
|
637
|
-
configuration: configuration,
|
|
638
|
-
cache_store: cache_store,
|
|
639
|
-
plugin_blueprints: blueprints,
|
|
640
|
-
explain: explain,
|
|
641
|
-
source_files: shareable_source_files
|
|
642
|
-
)
|
|
643
|
-
main = Ractor.main
|
|
644
|
-
main.send([:prepare, session.prepare_diagnostics])
|
|
645
|
-
|
|
646
|
-
loop do
|
|
647
|
-
msg = Ractor.receive
|
|
648
|
-
break if msg.nil?
|
|
649
|
-
|
|
650
|
-
main.send([:file, msg, session.analyze(msg)])
|
|
651
|
-
end
|
|
652
|
-
|
|
653
|
-
main.send([:done, session.drain_reporters])
|
|
654
|
-
end
|
|
655
|
-
end
|
|
656
|
-
|
|
657
|
-
files.each_with_index { |path, index| pool[index % pool.size].send(path) }
|
|
658
|
-
pool.each { |worker| worker.send(nil) }
|
|
659
|
-
|
|
660
|
-
prepare_diagnostics = nil
|
|
661
|
-
results_by_path = {}
|
|
662
|
-
done_count = 0
|
|
663
|
-
|
|
664
|
-
while done_count < pool.size
|
|
665
|
-
message = Ractor.receive
|
|
666
|
-
case message.first
|
|
667
|
-
when :prepare
|
|
668
|
-
prepare_diagnostics ||= message.last
|
|
669
|
-
when :file
|
|
670
|
-
results_by_path[message[1]] = message[2]
|
|
671
|
-
when :done
|
|
672
|
-
merge_worker_reporters(message.last)
|
|
673
|
-
done_count += 1
|
|
674
|
-
end
|
|
675
|
-
end
|
|
676
|
-
|
|
677
|
-
pool.each(&:join)
|
|
678
|
-
|
|
679
|
-
Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
|
|
680
|
-
end
|
|
681
|
-
|
|
682
|
-
# ADR-15 Amendment (2026-05-20) — fork-based worker pool, the
|
|
683
|
-
# active backend for `workers > 0`. Builds ONE {WorkerSession}
|
|
684
|
-
# on the parent, then `fork`s N children that copy-on-write
|
|
685
|
-
# inherit it. Each child analyses a contiguous slice of `files`
|
|
686
|
-
# and writes a Marshal'd `{results:, reporters:}` payload to a
|
|
687
|
-
# temp file; the parent `Process.wait`s every child, merges the
|
|
688
|
-
# payloads, and re-orders diagnostics by original path order.
|
|
689
|
-
#
|
|
690
|
-
# Separate processes have separate GC heaps and `vm->ci_table`
|
|
691
|
-
# (immune to Ruby Bug #22075) and copy-on-write-inherit every
|
|
692
|
-
# constant (no `Ractor.shareable?` constraint). See the ADR-15
|
|
693
|
-
# Amendment + docs/notes/20260520-ractor-pool-cruby-uaf.md.
|
|
694
|
-
#
|
|
695
|
-
# A child that exits non-zero (crash / unmarshalable payload) is
|
|
696
|
-
# degraded: the parent re-analyses that slice in-process and
|
|
697
|
-
# prepends a `pool-degraded` warning.
|
|
698
|
-
def analyze_files_in_fork_pool(files) # rubocop:disable Metrics/AbcSize
|
|
699
|
-
Environment::ClassRegistry.default
|
|
700
|
-
|
|
701
|
-
session = WorkerSession.new(
|
|
570
|
+
@diagnostic_aggregator = DiagnosticAggregator.new(
|
|
702
571
|
configuration: @configuration,
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
572
|
+
rbs_extended_reporter: @rbs_extended_reporter,
|
|
573
|
+
boundary_cross_reporter: @boundary_cross_reporter,
|
|
574
|
+
source_rbs_synthesis_reporter: @source_rbs_synthesis_reporter,
|
|
575
|
+
plugin_registry: -> { @plugin_registry },
|
|
576
|
+
dependency_source_index: -> { @dependency_source_index },
|
|
577
|
+
pool_mode: -> { pool_mode? },
|
|
578
|
+
cached_plugin_prepare_diagnostics: -> { @cached_plugin_prepare_diagnostics },
|
|
579
|
+
pre_eval_diagnostics_from_scanner: -> { @pre_eval_diagnostics_from_scanner },
|
|
580
|
+
synthesized_namespaces_snapshot: -> { @snapshots.synthesized_namespaces },
|
|
581
|
+
conformance_results_snapshot: -> { @snapshots.conformance_results }
|
|
709
582
|
)
|
|
710
|
-
# Force the full RBS load on the parent so children
|
|
711
|
-
# copy-on-write inherit a warm Environment rather than each
|
|
712
|
-
# rebuilding it after the fork.
|
|
713
|
-
session.environment.rbs_loader&.prewarm
|
|
714
|
-
snapshot_fork_pool_stats(session) if @collect_stats
|
|
715
|
-
|
|
716
|
-
worker_count = [@workers, files.size].min
|
|
717
|
-
slices = files.each_slice((files.size.to_f / worker_count).ceil).to_a
|
|
718
|
-
results_by_path = {}
|
|
719
|
-
|
|
720
|
-
degraded = Dir.mktmpdir("rigor-fork-pool") do |tmpdir|
|
|
721
|
-
children = slices.each_with_index.map do |slice, index|
|
|
722
|
-
out_path = File.join(tmpdir, "worker-#{index}")
|
|
723
|
-
{ pid: fork { run_fork_worker(session, slice, out_path) },
|
|
724
|
-
slice: slice, out_path: out_path }
|
|
725
|
-
end
|
|
726
|
-
collect_fork_results(children, results_by_path)
|
|
727
|
-
end
|
|
728
|
-
|
|
729
|
-
unless degraded.empty?
|
|
730
|
-
degraded.each { |path| results_by_path[path] = session.analyze(path) }
|
|
731
|
-
merge_worker_reporters(session.drain_reporters)
|
|
732
|
-
end
|
|
733
|
-
|
|
734
|
-
diagnostics = Array(session.prepare_diagnostics) +
|
|
735
|
-
files.flat_map { |path| results_by_path.fetch(path, []) }
|
|
736
|
-
degraded.empty? ? diagnostics : diagnostics.unshift(fork_degraded_diagnostic(degraded.size))
|
|
737
|
-
end
|
|
738
|
-
|
|
739
|
-
# Child-process body for {#analyze_files_in_fork_pool}. Analyses
|
|
740
|
-
# the slice with the copy-on-write-inherited session and writes
|
|
741
|
-
# the Marshal'd payload to `out_path`. `exit!` skips `at_exit` /
|
|
742
|
-
# stdio flush — the payload is already durable on disk by then.
|
|
743
|
-
def run_fork_worker(session, slice, out_path)
|
|
744
|
-
results = slice.to_h { |path| [path, session.analyze(path)] }
|
|
745
|
-
payload = { results: results, reporters: session.drain_reporters }
|
|
746
|
-
File.binwrite(out_path, Marshal.dump(payload))
|
|
747
|
-
exit!(0)
|
|
748
|
-
rescue StandardError
|
|
749
|
-
exit!(1)
|
|
750
|
-
end
|
|
751
|
-
|
|
752
|
-
# Snapshots `class_decl_paths` from the parent session's loader
|
|
753
|
-
# so end-of-run {RunStats} can attribute the RBS class universe.
|
|
754
|
-
def snapshot_fork_pool_stats(session)
|
|
755
|
-
loader = session.environment.rbs_loader
|
|
756
|
-
@class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
|
|
757
|
-
@signature_paths_snapshot = loader&.signature_paths || [].freeze
|
|
758
|
-
end
|
|
759
|
-
|
|
760
|
-
# Waits for every forked child, merges each successful payload
|
|
761
|
-
# into `results_by_path`, and returns the file paths whose
|
|
762
|
-
# worker exited abnormally (for in-process degrade).
|
|
763
|
-
def collect_fork_results(children, results_by_path)
|
|
764
|
-
degraded = []
|
|
765
|
-
children.each do |child|
|
|
766
|
-
_, status = Process.waitpid2(child[:pid])
|
|
767
|
-
payload = fork_worker_payload(status, child[:out_path])
|
|
768
|
-
if payload
|
|
769
|
-
results_by_path.merge!(payload.fetch(:results))
|
|
770
|
-
merge_worker_reporters(payload.fetch(:reporters))
|
|
771
|
-
else
|
|
772
|
-
degraded.concat(child[:slice])
|
|
773
|
-
end
|
|
774
|
-
end
|
|
775
|
-
degraded
|
|
776
|
-
end
|
|
777
|
-
|
|
778
|
-
# @return [Hash, nil] the child's `{results:, reporters:}`
|
|
779
|
-
# payload, or nil when the child exited abnormally or wrote no
|
|
780
|
-
# readable payload. `Marshal.load` is safe here: the blob was
|
|
781
|
-
# written by our own forked child to a temp file we created.
|
|
782
|
-
def fork_worker_payload(status, out_path)
|
|
783
|
-
return nil unless status.success? && File.exist?(out_path)
|
|
784
|
-
|
|
785
|
-
Marshal.load(File.binread(out_path)) # rubocop:disable Security/MarshalLoad
|
|
786
|
-
rescue StandardError
|
|
787
|
-
nil
|
|
788
583
|
end
|
|
789
584
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
585
|
+
# ADR-15 Phase 4b — pool mode is enabled when `@workers > 0`.
|
|
586
|
+
# Editor mode (`buffer:` non-nil) silently overrides pool
|
|
587
|
+
# mode to sequential. The real decision lives on
|
|
588
|
+
# {PoolCoordinator}; the predicate stays on the runner because
|
|
589
|
+
# `run_result_cacheable?` consults it (and a spec exercises it
|
|
590
|
+
# via `send`).
|
|
591
|
+
def pool_mode?
|
|
592
|
+
@pool_coordinator.pool_mode?
|
|
797
593
|
end
|
|
798
594
|
|
|
799
595
|
# End-of-run telemetry. Walks the cached
|
|
@@ -807,10 +603,10 @@ module Rigor
|
|
|
807
603
|
# Wall + RSS are single syscalls; total cost is bounded
|
|
808
604
|
# by the snapshot size (~1000-2000 entries).
|
|
809
605
|
def build_run_stats(wall_started_at:, expansion:)
|
|
810
|
-
snapshot = @
|
|
606
|
+
snapshot = @snapshots.class_decl_paths
|
|
811
607
|
project_sig, bundled = RunStats.partition_classes(
|
|
812
608
|
class_decl_paths: snapshot,
|
|
813
|
-
signature_paths: @
|
|
609
|
+
signature_paths: @snapshots.signature_paths
|
|
814
610
|
)
|
|
815
611
|
RunStats.new(
|
|
816
612
|
wall_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_started_at,
|
|
@@ -825,546 +621,6 @@ module Rigor
|
|
|
825
621
|
)
|
|
826
622
|
end
|
|
827
623
|
|
|
828
|
-
# ADR-15 Phase 4b.x — drives every cached RBS producer
|
|
829
|
-
# on the main Ractor so each worker can serve all
|
|
830
|
-
# reflection queries from disk (Marshal-load only).
|
|
831
|
-
# Builds a single coordinator-side {Environment} for
|
|
832
|
-
# this purpose; the env object is discarded immediately
|
|
833
|
-
# after the cache is warm — workers build their own
|
|
834
|
-
# `Environment.for_project` inside the Ractor body,
|
|
835
|
-
# which then routes through `cached_env` instead of
|
|
836
|
-
# `RBS::EnvironmentLoader.new`.
|
|
837
|
-
def prewarm_rbs_cache_for_pool
|
|
838
|
-
warm_env = Environment.for_project(
|
|
839
|
-
libraries: @configuration.libraries,
|
|
840
|
-
signature_paths: @configuration.signature_paths,
|
|
841
|
-
cache_store: @cache_store,
|
|
842
|
-
bundler_bundle_path: @configuration.bundler_bundle_path,
|
|
843
|
-
bundler_auto_detect: @configuration.bundler_auto_detect,
|
|
844
|
-
bundler_lockfile: @configuration.bundler_lockfile,
|
|
845
|
-
rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
|
|
846
|
-
rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect
|
|
847
|
-
)
|
|
848
|
-
warm_env.rbs_loader&.prewarm
|
|
849
|
-
end
|
|
850
|
-
|
|
851
|
-
# ADR-15 Phase 4b.x — pool-mode safety net. When pool
|
|
852
|
-
# mode is configured but a precondition fails (currently:
|
|
853
|
-
# `--no-cache` would force workers through
|
|
854
|
-
# `EnvironmentLoader.new`), degrade to sequential
|
|
855
|
-
# analysis with a `:warning` `pool-degraded` diagnostic
|
|
856
|
-
# at run start. The actual per-file analysis runs on
|
|
857
|
-
# the coordinator, identical to the default sequential
|
|
858
|
-
# path.
|
|
859
|
-
def analyze_files_sequentially_fallback(files, reason:)
|
|
860
|
-
environment = build_runner_environment
|
|
861
|
-
diagnostics = files.flat_map { |path| analyze_file(path, environment) }
|
|
862
|
-
loader = environment.rbs_loader
|
|
863
|
-
@class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
|
|
864
|
-
@signature_paths_snapshot = loader&.signature_paths || [].freeze
|
|
865
|
-
diagnostics.unshift(
|
|
866
|
-
Diagnostic.new(
|
|
867
|
-
path: ".rigor.yml", line: 1, column: 1,
|
|
868
|
-
message: "pool mode degraded to sequential: #{reason}",
|
|
869
|
-
severity: :warning, rule: "pool-degraded", source_family: :builtin
|
|
870
|
-
)
|
|
871
|
-
)
|
|
872
|
-
end
|
|
873
|
-
|
|
874
|
-
def merge_worker_reporters(drained)
|
|
875
|
-
rbs = drained.fetch(:rbs_extended)
|
|
876
|
-
rbs.fetch(:unresolved_payloads).each do |entry|
|
|
877
|
-
@rbs_extended_reporter.record_unresolved(
|
|
878
|
-
payload: entry.payload, source_location: entry.source_location
|
|
879
|
-
)
|
|
880
|
-
end
|
|
881
|
-
rbs.fetch(:lossy_projections).each do |entry|
|
|
882
|
-
@rbs_extended_reporter.record_lossy_projection(
|
|
883
|
-
head: entry.head, source_location: entry.source_location
|
|
884
|
-
)
|
|
885
|
-
end
|
|
886
|
-
drained.fetch(:boundary_cross).each do |entry|
|
|
887
|
-
@boundary_cross_reporter.record(
|
|
888
|
-
class_name: entry.class_name,
|
|
889
|
-
method_name: entry.method_name,
|
|
890
|
-
gem_name: entry.gem_name,
|
|
891
|
-
rbs_display: entry.rbs_display
|
|
892
|
-
)
|
|
893
|
-
end
|
|
894
|
-
# ADR-32 WD6 — merge per-worker synthesizer failures
|
|
895
|
-
# back into the coordinator's reporter. Fetched with a
|
|
896
|
-
# default empty array so older drains (pre-slice-2)
|
|
897
|
-
# remain compatible.
|
|
898
|
-
Array(drained[:source_rbs_synthesis]).each do |entry|
|
|
899
|
-
@source_rbs_synthesis_reporter.record(
|
|
900
|
-
plugin_id: entry.plugin_id, path: entry.path, message: entry.message
|
|
901
|
-
)
|
|
902
|
-
end
|
|
903
|
-
end
|
|
904
|
-
|
|
905
|
-
# Loads project-configured plugins through {Rigor::Plugin::Loader}
|
|
906
|
-
# and returns the resulting {Rigor::Plugin::Registry}. Loader
|
|
907
|
-
# failures are isolated: each surfaces as a `:plugin_loader`
|
|
908
|
-
# diagnostic on the run's `Result` rather than aborting the
|
|
909
|
-
# analysis. Plugins that load successfully but contribute no
|
|
910
|
-
# protocol hooks are inert in slice 1; later v0.1.0 slices
|
|
911
|
-
# wire the contribution merger through this registry.
|
|
912
|
-
def load_plugins
|
|
913
|
-
return Plugin::Registry::EMPTY if @configuration.plugins.empty?
|
|
914
|
-
|
|
915
|
-
services = Plugin::Services.new(
|
|
916
|
-
reflection: Reflection,
|
|
917
|
-
type: Type::Combinator,
|
|
918
|
-
configuration: @configuration,
|
|
919
|
-
cache_store: @cache_store,
|
|
920
|
-
trust_policy: build_trust_policy
|
|
921
|
-
)
|
|
922
|
-
if @plugin_requirer
|
|
923
|
-
Plugin::Loader.load(configuration: @configuration, services: services, requirer: @plugin_requirer)
|
|
924
|
-
else
|
|
925
|
-
Plugin::Loader.load(configuration: @configuration, services: services)
|
|
926
|
-
end
|
|
927
|
-
end
|
|
928
|
-
|
|
929
|
-
# Builds the {Rigor::Plugin::TrustPolicy} for this run. Trusted
|
|
930
|
-
# gems are the gem-name half of every entry in
|
|
931
|
-
# `Configuration#plugins`. Allowed read roots default to the
|
|
932
|
-
# project root (CWD), the project's signature_paths, and each
|
|
933
|
-
# trusted gem's `Gem::Specification#full_gem_path`, plus any
|
|
934
|
-
# extras the user listed under `plugins_io.allowed_paths`.
|
|
935
|
-
# Slice 2 keeps `network_policy` `:disabled` — the only value
|
|
936
|
-
# the configuration accepts today.
|
|
937
|
-
def build_trust_policy
|
|
938
|
-
trusted_gems = @configuration.plugins.map { |entry| trusted_gem_name(entry) }.uniq
|
|
939
|
-
roots = [Dir.pwd]
|
|
940
|
-
Array(@configuration.signature_paths).each { |sp| roots << File.expand_path(sp) }
|
|
941
|
-
trusted_gems.each do |gem_name|
|
|
942
|
-
path = trusted_gem_root(gem_name)
|
|
943
|
-
roots << path if path
|
|
944
|
-
end
|
|
945
|
-
@configuration.plugins_io_allowed_paths.each { |p| roots << File.expand_path(p) }
|
|
946
|
-
|
|
947
|
-
Plugin::TrustPolicy.new(
|
|
948
|
-
trusted_gems: trusted_gems,
|
|
949
|
-
allowed_read_roots: roots,
|
|
950
|
-
network_policy: @configuration.plugins_io_network,
|
|
951
|
-
allowed_url_hosts: @configuration.plugins_io_allowed_url_hosts
|
|
952
|
-
)
|
|
953
|
-
end
|
|
954
|
-
|
|
955
|
-
def trusted_gem_name(entry)
|
|
956
|
-
case entry
|
|
957
|
-
when String then entry
|
|
958
|
-
when Hash then entry["gem"] || entry["id"]
|
|
959
|
-
end
|
|
960
|
-
end
|
|
961
|
-
|
|
962
|
-
def trusted_gem_root(gem_name)
|
|
963
|
-
return nil if gem_name.nil? || gem_name.empty?
|
|
964
|
-
|
|
965
|
-
spec = Gem.loaded_specs[gem_name]
|
|
966
|
-
spec&.full_gem_path # rigor:disable undefined-method
|
|
967
|
-
rescue StandardError
|
|
968
|
-
nil
|
|
969
|
-
end
|
|
970
|
-
|
|
971
|
-
# ADR-8 § "Severity profile" — re-stamps each diagnostic's
|
|
972
|
-
# severity from the configured profile + per-rule
|
|
973
|
-
# overrides. Rules emit with their authored severity; the
|
|
974
|
-
# profile is the final filter. Diagnostics whose resolved
|
|
975
|
-
# severity is `:off` are dropped from the run result.
|
|
976
|
-
def apply_severity_profile(diagnostics)
|
|
977
|
-
diagnostics.filter_map { |diagnostic| stamp_severity(diagnostic) }
|
|
978
|
-
end
|
|
979
|
-
|
|
980
|
-
def stamp_severity(diagnostic)
|
|
981
|
-
return diagnostic if diagnostic.rule.nil?
|
|
982
|
-
|
|
983
|
-
resolved = Configuration::SeverityProfile.resolve(
|
|
984
|
-
rule: diagnostic.rule,
|
|
985
|
-
authored_severity: diagnostic.severity,
|
|
986
|
-
profile: @configuration.severity_profile,
|
|
987
|
-
overrides: @configuration.severity_overrides
|
|
988
|
-
)
|
|
989
|
-
return nil if resolved == :off
|
|
990
|
-
return diagnostic if resolved == diagnostic.severity
|
|
991
|
-
|
|
992
|
-
Diagnostic.new(
|
|
993
|
-
path: diagnostic.path,
|
|
994
|
-
line: diagnostic.line,
|
|
995
|
-
column: diagnostic.column,
|
|
996
|
-
message: diagnostic.message,
|
|
997
|
-
severity: resolved,
|
|
998
|
-
rule: diagnostic.rule,
|
|
999
|
-
source_family: diagnostic.source_family
|
|
1000
|
-
)
|
|
1001
|
-
end
|
|
1002
|
-
|
|
1003
|
-
def plugin_load_diagnostics
|
|
1004
|
-
@plugin_registry.load_errors.map do |error|
|
|
1005
|
-
Diagnostic.new(
|
|
1006
|
-
path: ".rigor.yml",
|
|
1007
|
-
line: 1,
|
|
1008
|
-
column: 1,
|
|
1009
|
-
message: error.message,
|
|
1010
|
-
severity: :error,
|
|
1011
|
-
rule: "load-error",
|
|
1012
|
-
source_family: :plugin_loader
|
|
1013
|
-
)
|
|
1014
|
-
end
|
|
1015
|
-
end
|
|
1016
|
-
|
|
1017
|
-
# ADR-10 § "Diagnostic prefix family" — surfaces gems
|
|
1018
|
-
# listed in `dependencies.source_inference` that RubyGems
|
|
1019
|
-
# could not resolve. The run continues; the gem simply
|
|
1020
|
-
# contributes nothing this session, mirroring the
|
|
1021
|
-
# plugin-load error envelope. Authored `:warning` because
|
|
1022
|
-
# an unresolvable gem usually means a typo or a missing
|
|
1023
|
-
# `bundle install` rather than a project-blocking problem;
|
|
1024
|
-
# the severity profile still re-stamps it.
|
|
1025
|
-
def dependency_source_diagnostics
|
|
1026
|
-
@dependency_source_index.unresolvable.map do |entry|
|
|
1027
|
-
Diagnostic.new(
|
|
1028
|
-
path: ".rigor.yml",
|
|
1029
|
-
line: 1,
|
|
1030
|
-
column: 1,
|
|
1031
|
-
message: "dependencies.source_inference[].gem #{entry.gem_name.inspect} could not be " \
|
|
1032
|
-
"resolved (#{entry.reason}); skipping",
|
|
1033
|
-
severity: :warning,
|
|
1034
|
-
rule: "dynamic.dependency-source.gem-not-found",
|
|
1035
|
-
source_family: :builtin
|
|
1036
|
-
)
|
|
1037
|
-
end
|
|
1038
|
-
end
|
|
1039
|
-
|
|
1040
|
-
# ADR-10 § "Budget interaction" / slice 4 — emits one
|
|
1041
|
-
# `:warning` per gem whose Walker run hit the
|
|
1042
|
-
# `dependencies.budget_per_gem` cap. The cap is a Walker-
|
|
1043
|
-
# side guard rail (slice 4 picks the (α) semantics from
|
|
1044
|
-
# ADR-10 WD4: harvesting stops, the dispatcher behaves
|
|
1045
|
-
# exactly as before for unrecorded methods). The
|
|
1046
|
-
# diagnostic names the gem and points the user at the
|
|
1047
|
-
# three remediations: ship RBS, reduce `mode:` from
|
|
1048
|
-
# `full` to `when_missing`, or de-list the gem.
|
|
1049
|
-
# ADR-10 § "config-conflict diagnostic" / 5d — surfaces
|
|
1050
|
-
# `Configuration::Dependencies` warnings accumulated
|
|
1051
|
-
# during `from_h` deduplication of the `includes:`-chain
|
|
1052
|
-
# source_inference array. Each warning describes a
|
|
1053
|
-
# per-gem mode conflict that the merge resolved
|
|
1054
|
-
# right-wins; the user sees one diagnostic per conflict.
|
|
1055
|
-
# `:warning` matches the user's "warn but don't block"
|
|
1056
|
-
# preference per the design discussion.
|
|
1057
|
-
def dependency_source_config_conflict_diagnostics
|
|
1058
|
-
@configuration.dependencies.warnings.map do |message|
|
|
1059
|
-
Diagnostic.new(
|
|
1060
|
-
path: ".rigor.yml",
|
|
1061
|
-
line: 1,
|
|
1062
|
-
column: 1,
|
|
1063
|
-
message: message,
|
|
1064
|
-
severity: :warning,
|
|
1065
|
-
rule: "dynamic.dependency-source.config-conflict",
|
|
1066
|
-
source_family: :builtin
|
|
1067
|
-
)
|
|
1068
|
-
end
|
|
1069
|
-
end
|
|
1070
|
-
|
|
1071
|
-
def dependency_source_budget_diagnostics
|
|
1072
|
-
budget = @configuration.dependencies.budget_per_gem
|
|
1073
|
-
@dependency_source_index.budget_exceeded.map do |gem_name|
|
|
1074
|
-
Diagnostic.new(
|
|
1075
|
-
path: ".rigor.yml",
|
|
1076
|
-
line: 1,
|
|
1077
|
-
column: 1,
|
|
1078
|
-
message: "dependencies.source_inference[].gem #{gem_name.inspect} exceeded the per-gem " \
|
|
1079
|
-
"catalog cap (#{budget} method definitions); the remaining methods fall back " \
|
|
1080
|
-
"to the existing RBS-or-Dynamic[top] boundary. Ship RBS for the gem, set " \
|
|
1081
|
-
"`mode: when_missing` instead of `full`, or de-list the gem.",
|
|
1082
|
-
severity: :warning,
|
|
1083
|
-
rule: "dynamic.dependency-source.budget-exceeded",
|
|
1084
|
-
source_family: :builtin
|
|
1085
|
-
)
|
|
1086
|
-
end
|
|
1087
|
-
end
|
|
1088
|
-
|
|
1089
|
-
# O4 Layer 3 slice 3 — graceful-degradation coverage
|
|
1090
|
-
# report. When the project has a `Gemfile.lock` (slice 1)
|
|
1091
|
-
# and one or more locked gems are not covered by ANY of
|
|
1092
|
-
# the four RBS resolution paths (`DEFAULT_LIBRARIES`,
|
|
1093
|
-
# `data/vendored_gem_sigs/`, slice-1 bundle-shipped
|
|
1094
|
-
# `sig/`, slice-2 `rbs_collection.lock.yaml`), emit a
|
|
1095
|
-
# single `:info` diagnostic summarising the uncovered set
|
|
1096
|
-
# so the user can act on it (run `rbs collection install`,
|
|
1097
|
-
# opt the gem into `dependencies.source_inference:`, or
|
|
1098
|
-
# accept the `Dynamic[T]` fallback).
|
|
1099
|
-
#
|
|
1100
|
-
# Suppressed when the lockfile is empty, when every gem
|
|
1101
|
-
# is covered, or when slice 1's `bundler.lockfile`
|
|
1102
|
-
# discovery returned nothing (no lockfile to read).
|
|
1103
|
-
def rbs_coverage_diagnostics
|
|
1104
|
-
locked = Environment::LockfileResolver.locked_gems(
|
|
1105
|
-
lockfile_path: @configuration.bundler_lockfile,
|
|
1106
|
-
project_root: Dir.pwd,
|
|
1107
|
-
auto_detect: @configuration.bundler_auto_detect
|
|
1108
|
-
)
|
|
1109
|
-
return [] if locked.empty?
|
|
1110
|
-
|
|
1111
|
-
bundle_sig_paths = Environment::BundleSigDiscovery.discover(
|
|
1112
|
-
bundle_path: @configuration.bundler_bundle_path,
|
|
1113
|
-
project_root: Dir.pwd,
|
|
1114
|
-
auto_detect: @configuration.bundler_auto_detect,
|
|
1115
|
-
locked_gems: locked
|
|
1116
|
-
)
|
|
1117
|
-
collection_paths = Environment::RbsCollectionDiscovery.discover(
|
|
1118
|
-
lockfile_path: @configuration.rbs_collection_lockfile,
|
|
1119
|
-
project_root: Dir.pwd,
|
|
1120
|
-
auto_detect: @configuration.rbs_collection_auto_detect
|
|
1121
|
-
)
|
|
1122
|
-
rows = Environment::RbsCoverageReport.classify(
|
|
1123
|
-
locked_gems: locked,
|
|
1124
|
-
default_libraries: Environment::DEFAULT_LIBRARIES,
|
|
1125
|
-
bundle_sig_paths: bundle_sig_paths,
|
|
1126
|
-
rbs_collection_paths: collection_paths
|
|
1127
|
-
)
|
|
1128
|
-
missing = Environment::RbsCoverageReport.missing(rows)
|
|
1129
|
-
return [] if missing.empty?
|
|
1130
|
-
|
|
1131
|
-
[build_rbs_coverage_missing_diagnostic(missing)]
|
|
1132
|
-
end
|
|
1133
|
-
|
|
1134
|
-
# Robustness uplift companion (ADR-5) — when the project's
|
|
1135
|
-
# `signature_paths:` RBS declared qualified names without their
|
|
1136
|
-
# enclosing namespace, `RbsLoader` synthesizes the missing
|
|
1137
|
-
# `module`s so the otherwise-inert signatures resolve. Surface a
|
|
1138
|
-
# single `:info` diagnostic naming them so the user knows their
|
|
1139
|
-
# sig set is malformed (`rbs validate` rejects it) and can fix it
|
|
1140
|
-
# at the source. Authored `:info`: the analysis already succeeded;
|
|
1141
|
-
# this is advisory, never a gate. Empty for a well-formed sig set.
|
|
1142
|
-
def rbs_synthesized_namespace_diagnostics
|
|
1143
|
-
synthesized = @synthesized_namespaces_snapshot
|
|
1144
|
-
return [] if synthesized.nil? || synthesized.empty?
|
|
1145
|
-
|
|
1146
|
-
[build_rbs_synthesized_namespace_diagnostic(synthesized)]
|
|
1147
|
-
end
|
|
1148
|
-
|
|
1149
|
-
# True when the project declares its own `signature_paths:` (the
|
|
1150
|
-
# only place the qualified-name-without-namespace mistake lives).
|
|
1151
|
-
def project_signature_paths?
|
|
1152
|
-
paths = @configuration.signature_paths
|
|
1153
|
-
!(paths.nil? || paths.empty?)
|
|
1154
|
-
end
|
|
1155
|
-
|
|
1156
|
-
def build_rbs_synthesized_namespace_diagnostic(synthesized)
|
|
1157
|
-
sample_size = 5
|
|
1158
|
-
sample = synthesized.first(sample_size)
|
|
1159
|
-
suffix = synthesized.size > sample_size ? ", and #{synthesized.size - sample_size} more" : ""
|
|
1160
|
-
Diagnostic.new(
|
|
1161
|
-
path: ".rigor.yml",
|
|
1162
|
-
line: 1,
|
|
1163
|
-
column: 1,
|
|
1164
|
-
message: "#{synthesized.size} RBS namespace(s) under `signature_paths:` are " \
|
|
1165
|
-
"referenced by qualified declarations (e.g. `class Foo::Bar`) but never " \
|
|
1166
|
-
"declared: #{sample.join(', ')}#{suffix}. `rbs validate` rejects this; " \
|
|
1167
|
-
"Rigor synthesized the missing `module`(s) so the signatures still " \
|
|
1168
|
-
"resolve. Declare each (`module <name>` / `class <name>`) in your RBS to " \
|
|
1169
|
-
"make the sig set valid upstream.",
|
|
1170
|
-
severity: :info,
|
|
1171
|
-
rule: "rbs.coverage.synthesized-namespace",
|
|
1172
|
-
source_family: :builtin
|
|
1173
|
-
)
|
|
1174
|
-
end
|
|
1175
|
-
|
|
1176
|
-
def build_rbs_coverage_missing_diagnostic(missing)
|
|
1177
|
-
sample_size = 5
|
|
1178
|
-
sample = missing.first(sample_size).map(&:gem_name)
|
|
1179
|
-
suffix = missing.size > sample_size ? ", and #{missing.size - sample_size} more" : ""
|
|
1180
|
-
Diagnostic.new(
|
|
1181
|
-
path: ".rigor.yml",
|
|
1182
|
-
line: 1,
|
|
1183
|
-
column: 1,
|
|
1184
|
-
message: "#{missing.size} gem(s) in Gemfile.lock have no RBS available: " \
|
|
1185
|
-
"#{sample.join(', ')}#{suffix}. " \
|
|
1186
|
-
"Consider `rbs collection install` to fetch community RBS from " \
|
|
1187
|
-
"`ruby/gem_rbs_collection`, ship `sig/` in the gem itself, or " \
|
|
1188
|
-
"opt the gem into `dependencies.source_inference:` in `.rigor.yml`.",
|
|
1189
|
-
severity: :info,
|
|
1190
|
-
rule: "rbs.coverage.missing-gem",
|
|
1191
|
-
source_family: :builtin
|
|
1192
|
-
)
|
|
1193
|
-
end
|
|
1194
|
-
|
|
1195
|
-
# ADR-13 slice 3b — drains the per-run
|
|
1196
|
-
# {RbsExtended::Reporter} into one diagnostic per accumulated
|
|
1197
|
-
# event:
|
|
1198
|
-
#
|
|
1199
|
-
# - `dynamic.rbs-extended.unresolved` for every annotation
|
|
1200
|
-
# payload the parser could not turn into a {Rigor::Type}.
|
|
1201
|
-
# Surfaces typos and references to plugin-supplied names
|
|
1202
|
-
# the project did not enable.
|
|
1203
|
-
# - `dynamic.shape.lossy-projection` for every shape-projection
|
|
1204
|
-
# type function (`pick_of`, …) applied to a carrier that
|
|
1205
|
-
# loses precision (anything other than `HashShape` / `Tuple`).
|
|
1206
|
-
#
|
|
1207
|
-
# Both are authored `:info`; the severity profile re-stamps
|
|
1208
|
-
# them per project taste. Path / line / column come from the
|
|
1209
|
-
# annotation's `RBS::Location` when available, falling back
|
|
1210
|
-
# to `.rigor.yml`-style file-level attribution otherwise.
|
|
1211
|
-
def rbs_extended_reporter_diagnostics
|
|
1212
|
-
return [] if @rbs_extended_reporter.empty?
|
|
1213
|
-
|
|
1214
|
-
unresolved = @rbs_extended_reporter.unresolved_payloads.map do |entry|
|
|
1215
|
-
build_reporter_diagnostic(
|
|
1216
|
-
entry.source_location,
|
|
1217
|
-
rule: "dynamic.rbs-extended.unresolved",
|
|
1218
|
-
message: "`RBS::Extended` directive payload could not be resolved: " \
|
|
1219
|
-
"#{entry.payload.inspect}. Check for typos or enable a plugin " \
|
|
1220
|
-
"that contributes the referenced type vocabulary."
|
|
1221
|
-
)
|
|
1222
|
-
end
|
|
1223
|
-
|
|
1224
|
-
lossy = @rbs_extended_reporter.lossy_projections.map do |entry|
|
|
1225
|
-
build_reporter_diagnostic(
|
|
1226
|
-
entry.source_location,
|
|
1227
|
-
rule: "dynamic.shape.lossy-projection",
|
|
1228
|
-
message: "Shape projection `#{entry.head}` applied to a carrier without a " \
|
|
1229
|
-
"literal shape; the projection degrades to the input type. Author " \
|
|
1230
|
-
"a `HashShape` / `Tuple` carrier or accept the unchanged result."
|
|
1231
|
-
)
|
|
1232
|
-
end
|
|
1233
|
-
|
|
1234
|
-
unresolved + lossy
|
|
1235
|
-
end
|
|
1236
|
-
|
|
1237
|
-
# ADR-10 slice 5c — drains the per-run
|
|
1238
|
-
# {DependencySourceInference::BoundaryCrossReporter} into
|
|
1239
|
-
# `dynamic.dependency-source.boundary-cross` `:info`
|
|
1240
|
-
# diagnostics. Each event flags a call site where RBS
|
|
1241
|
-
# dispatch produced a concrete answer AND a `mode: :full`
|
|
1242
|
-
# opt-in gem's source catalog ALSO contains an entry for
|
|
1243
|
-
# the same `(class_name, method_name)` — i.e., both
|
|
1244
|
-
# contracts have an opinion. RBS still wins on the
|
|
1245
|
-
# dispatch result; the diagnostic is purely advisory so
|
|
1246
|
-
# the user can verify the two contracts haven't drifted.
|
|
1247
|
-
#
|
|
1248
|
-
# Severity profile re-stamps the rule per project taste.
|
|
1249
|
-
# The diagnostic carries no `path` / `line` / `column`
|
|
1250
|
-
# because the crossing is per-method-per-gem, not
|
|
1251
|
-
# per-call-site — the diagnostic anchors at `.rigor.yml`
|
|
1252
|
-
# like the other `dependency-source.*` diagnostics that
|
|
1253
|
-
# report on opt-in configuration.
|
|
1254
|
-
# ADR-32 WD6 — drains the per-run
|
|
1255
|
-
# {Plugin::SourceRbsSynthesisReporter} into
|
|
1256
|
-
# `source-rbs-synthesis-failed` `:info` diagnostics. Each
|
|
1257
|
-
# entry names the plugin that owns the synthesizer, the
|
|
1258
|
-
# source file the rbs-inline parser couldn't process, and
|
|
1259
|
-
# the upstream error message. The synthesizer-emitting
|
|
1260
|
-
# plugin (currently only `rigor-rbs-inline`) treats a
|
|
1261
|
-
# parse failure as a no-contribution event so analysis
|
|
1262
|
-
# continues; this stream surfaces the failure so the user
|
|
1263
|
-
# can see which files contributed nothing and why.
|
|
1264
|
-
#
|
|
1265
|
-
# Severity profile re-stamps the rule per project taste.
|
|
1266
|
-
def source_rbs_synthesis_diagnostics
|
|
1267
|
-
return [] if @source_rbs_synthesis_reporter.empty?
|
|
1268
|
-
|
|
1269
|
-
@source_rbs_synthesis_reporter.entries.map do |entry|
|
|
1270
|
-
Diagnostic.new(
|
|
1271
|
-
path: entry.path, line: 1, column: 1,
|
|
1272
|
-
message: "plugin `#{entry.plugin_id}` failed to synthesise RBS from this file: " \
|
|
1273
|
-
"#{entry.message}. The file's analysis falls back to no inline-RBS " \
|
|
1274
|
-
"contribution. Fix the inline-RBS comment grammar or remove the " \
|
|
1275
|
-
"annotation to silence this diagnostic.",
|
|
1276
|
-
severity: :info,
|
|
1277
|
-
rule: "source-rbs-synthesis-failed",
|
|
1278
|
-
source_family: :builtin
|
|
1279
|
-
)
|
|
1280
|
-
end
|
|
1281
|
-
end
|
|
1282
|
-
|
|
1283
|
-
def boundary_cross_diagnostics
|
|
1284
|
-
return [] if @boundary_cross_reporter.empty?
|
|
1285
|
-
|
|
1286
|
-
@boundary_cross_reporter.entries.map do |entry|
|
|
1287
|
-
Diagnostic.new(
|
|
1288
|
-
path: ".rigor.yml", line: 1, column: 1,
|
|
1289
|
-
message: "`#{entry.class_name}##{entry.method_name}` is contributed by both " \
|
|
1290
|
-
"RBS (#{entry.rbs_display}) and the `mode: :full` opt-in gem " \
|
|
1291
|
-
"`#{entry.gem_name}`. RBS wins on dispatch; verify the gem source " \
|
|
1292
|
-
"has not drifted from its RBS contract.",
|
|
1293
|
-
severity: :info,
|
|
1294
|
-
rule: "dynamic.dependency-source.boundary-cross",
|
|
1295
|
-
source_family: :builtin
|
|
1296
|
-
)
|
|
1297
|
-
end
|
|
1298
|
-
end
|
|
1299
|
-
|
|
1300
|
-
def build_reporter_diagnostic(source_location, rule:, message:)
|
|
1301
|
-
path, line, column = location_fields(source_location)
|
|
1302
|
-
Diagnostic.new(
|
|
1303
|
-
path: path, line: line, column: column,
|
|
1304
|
-
message: message, severity: :info, rule: rule, source_family: :builtin
|
|
1305
|
-
)
|
|
1306
|
-
end
|
|
1307
|
-
|
|
1308
|
-
def location_fields(source_location)
|
|
1309
|
-
return [".rigor.yml", 1, 1] if source_location.nil?
|
|
1310
|
-
|
|
1311
|
-
path = location_path(source_location)
|
|
1312
|
-
line = source_location.respond_to?(:start_line) ? source_location.start_line : 1
|
|
1313
|
-
column = source_location.respond_to?(:start_column) ? source_location.start_column + 1 : 1
|
|
1314
|
-
[path, line, column]
|
|
1315
|
-
rescue StandardError
|
|
1316
|
-
[".rigor.yml", 1, 1]
|
|
1317
|
-
end
|
|
1318
|
-
|
|
1319
|
-
def location_path(source_location)
|
|
1320
|
-
buffer = source_location.respond_to?(:buffer) ? source_location.buffer : nil
|
|
1321
|
-
return ".rigor.yml" if buffer.nil? || !buffer.respond_to?(:name)
|
|
1322
|
-
|
|
1323
|
-
name = buffer.name.to_s
|
|
1324
|
-
name.empty? ? ".rigor.yml" : name
|
|
1325
|
-
end
|
|
1326
|
-
|
|
1327
|
-
# ADR-9 slice 3 — invokes every loaded plugin's `#prepare`
|
|
1328
|
-
# hook once per run, after the loader's `#init` pass and
|
|
1329
|
-
# before per-file iteration. Plugins publish facts here
|
|
1330
|
-
# for cross-plugin consumption via the shared
|
|
1331
|
-
# `services.fact_store`. Failures isolate as
|
|
1332
|
-
# `:plugin_loader runtime-error` diagnostics, mirroring the
|
|
1333
|
-
# `#diagnostics_for_file` raise envelope in
|
|
1334
|
-
# `plugin_runtime_error_diagnostic`.
|
|
1335
|
-
#
|
|
1336
|
-
# Slice 3 visits plugins in registration order. Slice 5
|
|
1337
|
-
# introduces topological ordering by `manifest(consumes:)`
|
|
1338
|
-
# so producers always run before consumers; until then,
|
|
1339
|
-
# `Configuration#plugins` order MUST be producer-first if
|
|
1340
|
-
# cross-plugin dependencies exist.
|
|
1341
|
-
def plugin_prepare_diagnostics
|
|
1342
|
-
return [] if @plugin_registry.empty?
|
|
1343
|
-
|
|
1344
|
-
@plugin_registry.plugins.flat_map { |plugin| invoke_plugin_prepare(plugin) }
|
|
1345
|
-
end
|
|
1346
|
-
|
|
1347
|
-
def invoke_plugin_prepare(plugin)
|
|
1348
|
-
plugin.prepare(plugin.services)
|
|
1349
|
-
[]
|
|
1350
|
-
rescue StandardError => e
|
|
1351
|
-
[plugin_prepare_error_diagnostic(plugin, e)]
|
|
1352
|
-
end
|
|
1353
|
-
|
|
1354
|
-
def plugin_prepare_error_diagnostic(plugin, error)
|
|
1355
|
-
plugin_id = safe_plugin_id(plugin)
|
|
1356
|
-
Diagnostic.new(
|
|
1357
|
-
path: ".rigor.yml",
|
|
1358
|
-
line: 1,
|
|
1359
|
-
column: 1,
|
|
1360
|
-
message: "plugin #{plugin_id.inspect} raised during prepare: " \
|
|
1361
|
-
"#{error.class}: #{error.message}",
|
|
1362
|
-
severity: :error,
|
|
1363
|
-
rule: "runtime-error",
|
|
1364
|
-
source_family: :plugin_loader
|
|
1365
|
-
)
|
|
1366
|
-
end
|
|
1367
|
-
|
|
1368
624
|
# ADR-7 § "Slice 5-A/5-B" — invokes every loaded plugin's
|
|
1369
625
|
# per-file diagnostic emission hook
|
|
1370
626
|
# (`Plugin::Base#diagnostics_for_file`) and re-stamps the
|
|
@@ -1376,17 +632,42 @@ module Rigor
|
|
|
1376
632
|
# I/O Policy" — a raise from one plugin becomes a
|
|
1377
633
|
# `:plugin_loader` `runtime-error` diagnostic without
|
|
1378
634
|
# affecting other plugins or the rest of the run.
|
|
635
|
+
# ADR-52 WD1 — only the plugins that overrode
|
|
636
|
+
# `#diagnostics_for_file` or declared a `node_rule` are visited
|
|
637
|
+
# (`contribution_index.for_file_diagnostics`); a skipped plugin's
|
|
638
|
+
# two hooks could only have returned `[]`.
|
|
1379
639
|
def plugin_emitted_diagnostics(path, root, scope)
|
|
1380
640
|
return [] if @plugin_registry.empty?
|
|
1381
641
|
|
|
1382
|
-
|
|
1383
|
-
|
|
642
|
+
# ADR-52 WD4 — one engine-owned AST walk per file dispatches each
|
|
643
|
+
# node to every matching (plugin, rule); the per-plugin results
|
|
644
|
+
# are bucketed in registry order so emission stays plugin-major
|
|
645
|
+
# (byte-identical with the old per-plugin walk).
|
|
646
|
+
node_results = node_rule_results_by_plugin(path, root, scope)
|
|
647
|
+
|
|
648
|
+
@plugin_registry.contribution_index.for_file_diagnostics.flat_map do |plugin|
|
|
649
|
+
collect_plugin_diagnostics(plugin, path, root, scope, node_results[plugin])
|
|
1384
650
|
end
|
|
1385
651
|
end
|
|
1386
652
|
|
|
1387
|
-
def
|
|
653
|
+
def node_rule_results_by_plugin(path, root, scope)
|
|
654
|
+
walk = @plugin_registry.node_rule_walk
|
|
655
|
+
return {}.compare_by_identity if walk.empty?
|
|
656
|
+
|
|
657
|
+
results = walk.diagnostics_for_file(path: path, scope: scope, root: root)
|
|
658
|
+
results.each_with_object({}.compare_by_identity) do |result, by_plugin|
|
|
659
|
+
by_plugin[result.plugin] = result
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def collect_plugin_diagnostics(plugin, path, root, scope, node_result)
|
|
1388
664
|
raw = Array(plugin.diagnostics_for_file(path: path, scope: scope, root: root))
|
|
1389
|
-
|
|
665
|
+
# A node-rule context/rule raise isolates the whole plugin's
|
|
666
|
+
# node-rule contribution, matching the old combined per-plugin
|
|
667
|
+
# rescue (which discarded `diagnostics_for_file` output too).
|
|
668
|
+
raise node_result.error if node_result&.error
|
|
669
|
+
|
|
670
|
+
raw += node_result.diagnostics if node_result
|
|
1390
671
|
raw.map { |diagnostic| stamp_plugin_diagnostic(diagnostic, plugin.manifest.id) }
|
|
1391
672
|
rescue StandardError => e
|
|
1392
673
|
[plugin_runtime_error_diagnostic(path, plugin, e)]
|
|
@@ -1455,7 +736,8 @@ module Rigor
|
|
|
1455
736
|
|
|
1456
737
|
def accept_as_ruby_file?(path)
|
|
1457
738
|
(File.file?(path) && path.end_with?(".rb")) ||
|
|
1458
|
-
(@buffer && path == @buffer.logical_path)
|
|
739
|
+
(@buffer && path == @buffer.logical_path) ||
|
|
740
|
+
@in_memory_sources&.key?(path)
|
|
1459
741
|
end
|
|
1460
742
|
|
|
1461
743
|
# `Configuration#exclude_patterns` is a list of glob patterns
|
|
@@ -1494,6 +776,10 @@ module Rigor
|
|
|
1494
776
|
# LOGICAL path. Non-binding paths go through the cheaper
|
|
1495
777
|
# `Prism.parse_file` codepath unchanged.
|
|
1496
778
|
def parse_source(path)
|
|
779
|
+
if @in_memory_sources&.key?(path)
|
|
780
|
+
return Prism.parse(@in_memory_sources[path], filepath: path, version: @configuration.target_ruby)
|
|
781
|
+
end
|
|
782
|
+
|
|
1497
783
|
physical = @buffer ? @buffer.resolve(path) : path
|
|
1498
784
|
return Prism.parse_file(physical, version: @configuration.target_ruby) if physical == path
|
|
1499
785
|
|
|
@@ -1507,25 +793,57 @@ module Rigor
|
|
|
1507
793
|
# without the project pre-pass (e.g. a single-file probe)
|
|
1508
794
|
# keeps an empty seed.
|
|
1509
795
|
def seed_project_scope(scope)
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
796
|
+
tables = project_scope_seed_tables
|
|
797
|
+
return scope if tables.empty?
|
|
798
|
+
|
|
799
|
+
scope.with_discovery(scope.discovery.with(**tables))
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
# The cross-file pre-pass tables {#seed_project_scope} applies, as a
|
|
803
|
+
# plain Hash so the fork-pool path can hand the same seed to its
|
|
804
|
+
# {WorkerSession} (whose per-file scopes would otherwise miss every
|
|
805
|
+
# cross-file def — ADR-15 sequential-equivalence contract).
|
|
806
|
+
def project_scope_seed_tables
|
|
807
|
+
tables = {}
|
|
808
|
+
tables[:discovered_classes] = @project_discovered_classes unless @project_discovered_classes.empty?
|
|
809
|
+
tables[:discovered_def_nodes] = @project_discovered_def_nodes unless @project_discovered_def_nodes.empty?
|
|
810
|
+
tables[:discovered_def_sources] = @project_discovered_def_sources unless @project_discovered_def_sources.empty?
|
|
1517
811
|
unless @project_discovered_superclasses.empty?
|
|
1518
|
-
|
|
812
|
+
tables[:discovered_superclasses] = @project_discovered_superclasses
|
|
1519
813
|
end
|
|
1520
|
-
|
|
814
|
+
tables[:discovered_includes] = @project_discovered_includes unless @project_discovered_includes.empty?
|
|
1521
815
|
unless @project_discovered_method_visibilities.empty?
|
|
1522
|
-
|
|
816
|
+
tables[:discovered_method_visibilities] = @project_discovered_method_visibilities
|
|
817
|
+
end
|
|
818
|
+
tables[:discovered_methods] = @project_discovered_methods unless @project_discovered_methods.empty?
|
|
819
|
+
tables[:data_member_layouts] = @project_data_member_layouts unless @project_data_member_layouts.empty?
|
|
820
|
+
# ADR-46 slice 1 — the class-declaration source map is read only by
|
|
821
|
+
# the ancestry accessors during dependency recording, so seed it
|
|
822
|
+
# only when recording is on; a normal run never carries it.
|
|
823
|
+
if @record_dependencies && !@project_discovered_class_sources.empty?
|
|
824
|
+
tables[:discovered_class_sources] = @project_discovered_class_sources
|
|
1523
825
|
end
|
|
1524
|
-
|
|
1525
|
-
scope
|
|
826
|
+
tables
|
|
1526
827
|
end
|
|
1527
828
|
|
|
1528
|
-
|
|
829
|
+
# ADR-46 slice 1 — when dependency recording is enabled, wrap the
|
|
830
|
+
# per-file analysis so the cross-file reads its inference makes are
|
|
831
|
+
# captured into `file_dependencies[path]`. Off by default: a normal
|
|
832
|
+
# run calls the body directly and the instrumented `Scope` accessors
|
|
833
|
+
# short-circuit on `DependencyRecorder.active? == false`. Recording
|
|
834
|
+
# is observational, so diagnostics are byte-identical either way.
|
|
835
|
+
def analyze_file(path, environment)
|
|
836
|
+
return analyze_file_body(path, environment) unless @record_dependencies
|
|
837
|
+
|
|
838
|
+
diagnostics = nil
|
|
839
|
+
record = DependencyRecorder.record_for(path) do
|
|
840
|
+
diagnostics = analyze_file_body(path, environment)
|
|
841
|
+
end
|
|
842
|
+
@file_dependencies[path] = record
|
|
843
|
+
diagnostics
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
def analyze_file_body(path, environment) # rubocop:disable Metrics/MethodLength
|
|
1529
847
|
parse_result = parse_source(path)
|
|
1530
848
|
unless parse_result.errors.empty?
|
|
1531
849
|
return [] if ErbTemplateDetector.template?(parse_result)
|
|
@@ -1534,11 +852,20 @@ module Rigor
|
|
|
1534
852
|
end
|
|
1535
853
|
|
|
1536
854
|
scope = seed_project_scope(Scope.empty(environment: environment, source_path: path))
|
|
1537
|
-
|
|
855
|
+
# ADR-24 slice 4a/4 — record unresolved implicit-self calls during the
|
|
856
|
+
# typing pass ONLY (not CheckRules, whose own `type_of` queries would
|
|
857
|
+
# otherwise re-trigger the choke-point). `self_call_misses` feeds the
|
|
858
|
+
# `call.self-undefined-method` collector; the recorder is inert unless
|
|
859
|
+
# the rule is active or `record_self_calls:` opted in.
|
|
860
|
+
index = nil
|
|
861
|
+
self_call_record = with_self_call_recording(path) do
|
|
862
|
+
index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
|
|
863
|
+
end
|
|
1538
864
|
diagnostics = CheckRules.diagnose(
|
|
1539
865
|
path: path,
|
|
1540
866
|
root: parse_result.value,
|
|
1541
867
|
scope_index: index,
|
|
868
|
+
self_call_misses: self_call_record ? self_call_record.calls : [],
|
|
1542
869
|
comments: parse_result.comments,
|
|
1543
870
|
disabled_rules: @configuration.disabled_rules
|
|
1544
871
|
)
|
|
@@ -1566,6 +893,46 @@ module Rigor
|
|
|
1566
893
|
]
|
|
1567
894
|
end
|
|
1568
895
|
|
|
896
|
+
# ADR-24 slice 4a — runs `block` (the typing pass) with the self-call
|
|
897
|
+
# recorder active when either the test-only `record_self_calls:` flag is
|
|
898
|
+
# set or the `call.self-undefined-method` rule resolves to a firing
|
|
899
|
+
# severity. Returns the frozen {SelfCallResolutionRecorder::Record}, or
|
|
900
|
+
# nil when recording is inactive (the common path — one integer read).
|
|
901
|
+
def with_self_call_recording(path, &)
|
|
902
|
+
unless self_call_recording_active?
|
|
903
|
+
yield
|
|
904
|
+
return nil
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
record = SelfCallResolutionRecorder.record_for(path, &)
|
|
908
|
+
@unresolved_self_calls[path] = record
|
|
909
|
+
record
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
def self_call_recording_active?
|
|
913
|
+
@record_self_calls || self_undefined_rule_active?
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
# Memoised: the rule fires only when its resolved severity is not `:off`
|
|
917
|
+
# and it is not in `disable:`. Default profiles map it to `:off`, so a
|
|
918
|
+
# normal run never activates the recorder (pending the external WD4
|
|
919
|
+
# corpus FP gate — see ADR-24 § "Slice 4"); a project opts in via
|
|
920
|
+
# `severity_overrides:`.
|
|
921
|
+
def self_undefined_rule_active?
|
|
922
|
+
return @self_undefined_rule_active unless @self_undefined_rule_active.nil?
|
|
923
|
+
|
|
924
|
+
rule = CheckRules::RULE_SELF_UNDEFINED_METHOD
|
|
925
|
+
@self_undefined_rule_active =
|
|
926
|
+
if @configuration.disabled_rules.include?(rule) || @configuration.disabled_rules.include?("call")
|
|
927
|
+
false
|
|
928
|
+
else
|
|
929
|
+
Configuration::SeverityProfile.resolve(
|
|
930
|
+
rule: rule, authored_severity: :warning,
|
|
931
|
+
profile: @configuration.severity_profile, overrides: @configuration.severity_overrides
|
|
932
|
+
) != :off
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
|
|
1569
936
|
# v0.0.2 #10 — fail-soft fallback explanation. When
|
|
1570
937
|
# `--explain` is set the runner additionally walks the
|
|
1571
938
|
# file with `Rigor::Inference::CoverageScanner` and emits
|