rigortype 0.1.4 → 0.1.6
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 +69 -56
- data/lib/rigor/analysis/buffer_binding.rb +36 -0
- data/lib/rigor/analysis/check_rules.rb +11 -1
- data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
- data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
- data/lib/rigor/analysis/fact_store.rb +15 -3
- data/lib/rigor/analysis/project_scan.rb +39 -0
- data/lib/rigor/analysis/result.rb +11 -3
- data/lib/rigor/analysis/run_stats.rb +193 -0
- data/lib/rigor/analysis/runner.rb +681 -19
- data/lib/rigor/analysis/worker_session.rb +339 -0
- data/lib/rigor/builtins/hkt_builtins.rb +342 -0
- data/lib/rigor/builtins/imported_refinements.rb +6 -2
- data/lib/rigor/builtins/regex_refinement.rb +17 -12
- data/lib/rigor/builtins/static_return_refinements.rb +120 -0
- data/lib/rigor/cache/rbs_descriptor.rb +3 -1
- data/lib/rigor/cache/store.rb +72 -9
- data/lib/rigor/cli/lsp_command.rb +129 -0
- data/lib/rigor/cli/type_of_command.rb +44 -5
- data/lib/rigor/cli.rb +122 -10
- data/lib/rigor/configuration.rb +168 -7
- data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
- data/lib/rigor/environment/class_registry.rb +12 -3
- data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
- data/lib/rigor/environment/lockfile_resolver.rb +125 -0
- data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
- data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
- data/lib/rigor/environment/rbs_loader.rb +238 -7
- data/lib/rigor/environment/reflection.rb +152 -0
- data/lib/rigor/environment/reporters.rb +40 -0
- data/lib/rigor/environment.rb +179 -10
- data/lib/rigor/inference/acceptance.rb +83 -4
- data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
- data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
- data/lib/rigor/inference/expression_typer.rb +59 -2
- data/lib/rigor/inference/hkt_body.rb +171 -0
- data/lib/rigor/inference/hkt_body_parser.rb +363 -0
- data/lib/rigor/inference/hkt_reducer.rb +256 -0
- data/lib/rigor/inference/hkt_registry.rb +223 -0
- data/lib/rigor/inference/macro_block_self_type.rb +96 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +126 -31
- data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
- data/lib/rigor/inference/method_dispatcher.rb +282 -6
- data/lib/rigor/inference/method_parameter_binder.rb +21 -11
- data/lib/rigor/inference/narrowing.rb +127 -8
- data/lib/rigor/inference/project_patched_methods.rb +70 -0
- data/lib/rigor/inference/project_patched_scanner.rb +210 -0
- data/lib/rigor/inference/scope_indexer.rb +156 -12
- data/lib/rigor/inference/statement_evaluator.rb +106 -6
- data/lib/rigor/inference/synthetic_method.rb +86 -0
- data/lib/rigor/inference/synthetic_method_index.rb +82 -0
- data/lib/rigor/inference/synthetic_method_scanner.rb +599 -0
- data/lib/rigor/language_server/buffer_table.rb +63 -0
- data/lib/rigor/language_server/completion_provider.rb +438 -0
- data/lib/rigor/language_server/debouncer.rb +86 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
- data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
- data/lib/rigor/language_server/folding_range_provider.rb +75 -0
- data/lib/rigor/language_server/hover_provider.rb +74 -0
- data/lib/rigor/language_server/hover_renderer.rb +312 -0
- data/lib/rigor/language_server/loop.rb +71 -0
- data/lib/rigor/language_server/project_context.rb +145 -0
- data/lib/rigor/language_server/selection_range_provider.rb +93 -0
- data/lib/rigor/language_server/server.rb +384 -0
- data/lib/rigor/language_server/signature_help_provider.rb +249 -0
- data/lib/rigor/language_server/synchronized_writer.rb +28 -0
- data/lib/rigor/language_server/uri.rb +40 -0
- data/lib/rigor/language_server.rb +29 -0
- data/lib/rigor/plugin/base.rb +63 -0
- data/lib/rigor/plugin/blueprint.rb +60 -0
- data/lib/rigor/plugin/loader.rb +3 -1
- data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
- data/lib/rigor/plugin/macro/external_file.rb +143 -0
- data/lib/rigor/plugin/macro/heredoc_template.rb +315 -0
- data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
- data/lib/rigor/plugin/macro.rb +31 -0
- data/lib/rigor/plugin/manifest.rb +127 -9
- data/lib/rigor/plugin/registry.rb +51 -2
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
- data/lib/rigor/rbs_extended.rb +82 -2
- data/lib/rigor/sig_gen/generator.rb +12 -3
- data/lib/rigor/trinary.rb +15 -11
- data/lib/rigor/type/app.rb +107 -0
- data/lib/rigor/type/bot.rb +6 -3
- data/lib/rigor/type/combinator.rb +12 -1
- data/lib/rigor/type/integer_range.rb +7 -7
- data/lib/rigor/type/refined.rb +18 -12
- data/lib/rigor/type/top.rb +4 -3
- data/lib/rigor/type.rb +1 -0
- data/lib/rigor/type_node/generic.rb +7 -1
- data/lib/rigor/type_node/identifier.rb +9 -1
- data/lib/rigor/type_node/string_literal.rb +4 -1
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +11 -4
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor/plugin/blueprint.rbs +7 -0
- data/sig/rigor/plugin/manifest.rbs +1 -1
- data/sig/rigor/plugin/registry.rbs +14 -1
- data/sig/rigor.rbs +37 -2
- metadata +92 -1
|
@@ -11,11 +11,17 @@ require_relative "../reflection"
|
|
|
11
11
|
require_relative "../type/combinator"
|
|
12
12
|
require_relative "../inference/coverage_scanner"
|
|
13
13
|
require_relative "../inference/scope_indexer"
|
|
14
|
+
require_relative "../inference/synthetic_method_scanner"
|
|
15
|
+
require_relative "../inference/project_patched_scanner"
|
|
14
16
|
require_relative "../inference/method_dispatcher/file_folding"
|
|
17
|
+
require_relative "buffer_binding"
|
|
15
18
|
require_relative "check_rules"
|
|
16
19
|
require_relative "dependency_source_inference"
|
|
17
20
|
require_relative "diagnostic"
|
|
21
|
+
require_relative "project_scan"
|
|
18
22
|
require_relative "result"
|
|
23
|
+
require_relative "run_stats"
|
|
24
|
+
require_relative "worker_session"
|
|
19
25
|
|
|
20
26
|
module Rigor
|
|
21
27
|
module Analysis
|
|
@@ -35,19 +41,77 @@ module Rigor
|
|
|
35
41
|
# run; the CLI's `--no-cache` flag wires `nil` through.
|
|
36
42
|
# v0.0.9 group A slice 1 introduces the surface; later
|
|
37
43
|
# slices route real producers through it.
|
|
38
|
-
|
|
44
|
+
# @param workers [Integer] ADR-15 Phase 4b — when greater
|
|
45
|
+
# than zero, per-file analysis dispatches across a pool of
|
|
46
|
+
# N Ractor workers built around {WorkerSession}. Default
|
|
47
|
+
# `0` keeps the sequential code path bit-for-bit
|
|
48
|
+
# unchanged. Phase 4c will wire the CLI / `.rigor.yml`
|
|
49
|
+
# surface that produces non-zero values; this slice
|
|
50
|
+
# leaves the parameter as a programmatic opt-in only.
|
|
51
|
+
# @param collect_stats [Boolean] when true (default), `#run`
|
|
52
|
+
# builds a {RunStats} summary exposed via `result.stats`
|
|
53
|
+
# — this forces the RBS env build at end-of-run so the
|
|
54
|
+
# `class_decl_paths` snapshot has real source attribution.
|
|
55
|
+
# Set to false to skip the stats summary entirely; the
|
|
56
|
+
# CLI's `--no-stats` threads `false` through to keep
|
|
57
|
+
# trivial-fixture runs from warming `.rigor/cache`.
|
|
58
|
+
# @param prebuilt [Rigor::Analysis::ProjectScan, nil] when
|
|
59
|
+
# supplied, the runner adopts the pre-built plugin
|
|
60
|
+
# registry / dependency-source index / scanner outputs
|
|
61
|
+
# from the snapshot and skips the per-call pre-passes
|
|
62
|
+
# that produce them. Used by long-lived integrations
|
|
63
|
+
# (`Rigor::LanguageServer::ProjectContext`) to keep
|
|
64
|
+
# per-buffer requests fast — scanners walk the project
|
|
65
|
+
# once per generation rather than once per request, and
|
|
66
|
+
# plugin `#prepare` runs once per generation rather than
|
|
67
|
+
# once per request. Watched-file invalidation is the
|
|
68
|
+
# owner's responsibility; the runner trusts the snapshot
|
|
69
|
+
# it was given.
|
|
70
|
+
# @param environment [Rigor::Environment, nil] opt-in
|
|
71
|
+
# Environment override. When supplied, sequential mode uses
|
|
72
|
+
# the provided env instance in `#analyze_files` instead of
|
|
73
|
+
# building a fresh one via `Environment.for_project`, and
|
|
74
|
+
# attaches the runner's per-run reporter pair onto the
|
|
75
|
+
# env's mutable `Reporters` slot via
|
|
76
|
+
# `Environment#attach_reporters!`. Long-lived consumers
|
|
77
|
+
# (LSP `ProjectContext`) pass a shared env so per-publish
|
|
78
|
+
# work doesn't repeat the `Environment.for_project` build
|
|
79
|
+
# (bundler / lockfile / collection discovery, RbsLoader
|
|
80
|
+
# construction). Pool mode ignores the override — each
|
|
81
|
+
# worker continues to build its own Environment.
|
|
82
|
+
def initialize(configuration:, explain: false, # rubocop:disable Metrics/ParameterLists
|
|
39
83
|
cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
|
|
40
|
-
plugin_requirer: nil
|
|
84
|
+
plugin_requirer: nil, workers: 0, collect_stats: true,
|
|
85
|
+
buffer: nil, prebuilt: nil, environment: nil)
|
|
41
86
|
@configuration = configuration
|
|
42
87
|
@explain = explain
|
|
43
|
-
@cache_store = cache_store
|
|
88
|
+
@cache_store = enforce_read_only_cache(cache_store, buffer)
|
|
44
89
|
@plugin_requirer = plugin_requirer
|
|
90
|
+
@workers = workers
|
|
91
|
+
@collect_stats = collect_stats
|
|
92
|
+
@buffer = buffer
|
|
93
|
+
@prebuilt = prebuilt
|
|
94
|
+
@environment_override = environment
|
|
45
95
|
@plugin_registry = Plugin::Registry::EMPTY
|
|
46
96
|
@dependency_source_index = DependencySourceInference::Index::EMPTY
|
|
47
97
|
@rbs_extended_reporter = RbsExtended::Reporter.new
|
|
48
98
|
@boundary_cross_reporter = DependencySourceInference::BoundaryCrossReporter.new
|
|
99
|
+
# `#run` resets these for each invocation; pre-seed them to
|
|
100
|
+
# empty containers so `build_run_stats` / `pre_file_diagnostics`
|
|
101
|
+
# (private, called only from `#run`) can read them without
|
|
102
|
+
# nil-guards.
|
|
103
|
+
@class_decl_paths_snapshot = {}.freeze
|
|
104
|
+
@signature_paths_snapshot = [].freeze
|
|
105
|
+
@cached_plugin_prepare_diagnostics = [].freeze
|
|
106
|
+
@project_discovered_classes = {}.freeze
|
|
49
107
|
end
|
|
50
108
|
|
|
109
|
+
# ADR-pending editor mode — present when the runner is wired
|
|
110
|
+
# for the `--tmp-file` / `--instead-of` buffer-binding shape
|
|
111
|
+
# (`docs/design/20260516-editor-mode.md`). Nil for normal
|
|
112
|
+
# project runs.
|
|
113
|
+
attr_reader :buffer
|
|
114
|
+
|
|
51
115
|
# Walks every Ruby file under `paths`, parses it, builds a
|
|
52
116
|
# per-node scope index through
|
|
53
117
|
# `Rigor::Inference::ScopeIndexer`, and runs the
|
|
@@ -60,29 +124,189 @@ module Rigor
|
|
|
60
124
|
Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths =
|
|
61
125
|
@configuration.fold_platform_specific_paths
|
|
62
126
|
|
|
127
|
+
wall_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
128
|
+
|
|
63
129
|
target_ruby_error = validate_target_ruby
|
|
64
130
|
return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
|
|
65
131
|
|
|
66
|
-
@plugin_registry = load_plugins
|
|
67
|
-
@dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
|
|
68
|
-
environment = Environment.for_project(
|
|
69
|
-
libraries: @configuration.libraries,
|
|
70
|
-
signature_paths: @configuration.signature_paths,
|
|
71
|
-
cache_store: @cache_store,
|
|
72
|
-
plugin_registry: @plugin_registry,
|
|
73
|
-
dependency_source_index: @dependency_source_index,
|
|
74
|
-
rbs_extended_reporter: @rbs_extended_reporter,
|
|
75
|
-
boundary_cross_reporter: @boundary_cross_reporter
|
|
76
|
-
)
|
|
77
132
|
expansion = expand_paths(paths)
|
|
133
|
+
@class_decl_paths_snapshot = {}.freeze
|
|
134
|
+
@signature_paths_snapshot = []
|
|
135
|
+
|
|
136
|
+
if @prebuilt
|
|
137
|
+
adopt_prebuilt_project_scan(@prebuilt)
|
|
138
|
+
else
|
|
139
|
+
run_project_pre_passes(expansion: expansion)
|
|
140
|
+
end
|
|
78
141
|
|
|
79
142
|
diagnostics = pre_file_diagnostics(expansion)
|
|
80
|
-
diagnostics +=
|
|
143
|
+
diagnostics += analyze_files(target_files(expansion))
|
|
81
144
|
diagnostics += rbs_extended_reporter_diagnostics
|
|
82
145
|
diagnostics += boundary_cross_diagnostics
|
|
83
146
|
|
|
84
|
-
Result.new(
|
|
147
|
+
Result.new(
|
|
148
|
+
diagnostics: apply_severity_profile(diagnostics),
|
|
149
|
+
stats: @collect_stats ? build_run_stats(wall_started_at: wall_started_at, expansion: expansion) : nil
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Runs every project-wide pre-pass (`load_plugins` +
|
|
154
|
+
# `plugin#prepare` + dependency-source builder +
|
|
155
|
+
# synthetic-method scanner + project-patched scanner)
|
|
156
|
+
# exactly once, then returns a frozen
|
|
157
|
+
# {Rigor::Analysis::ProjectScan} snapshot.
|
|
158
|
+
#
|
|
159
|
+
# Long-lived integrations (`Rigor::LanguageServer::ProjectContext`)
|
|
160
|
+
# call this once per project-state generation and feed the
|
|
161
|
+
# snapshot back into `Runner.new(prebuilt: scan)` for every
|
|
162
|
+
# subsequent per-buffer publish. The cold pre-pass cost is
|
|
163
|
+
# paid once per generation rather than once per keystroke.
|
|
164
|
+
#
|
|
165
|
+
# Notes for callers:
|
|
166
|
+
# - The runner this method is called on may be a "build only"
|
|
167
|
+
# instance — `@buffer` is typically nil so the scanners
|
|
168
|
+
# observe on-disk bytes for the full project. Callers that
|
|
169
|
+
# want pre-passes to see a particular buffer's edits should
|
|
170
|
+
# build the runner WITH `buffer:` set.
|
|
171
|
+
# - The returned ProjectScan is frozen and shareable; the
|
|
172
|
+
# underlying `plugin_registry` is the same object that ran
|
|
173
|
+
# `#prepare`, so the per-plugin `services.fact_store` is
|
|
174
|
+
# already populated for subsequent dispatch use.
|
|
175
|
+
def prepare_project_scan(paths: @configuration.paths)
|
|
176
|
+
expansion = expand_paths(paths)
|
|
177
|
+
run_project_pre_passes(expansion: expansion)
|
|
178
|
+
ProjectScan.new(
|
|
179
|
+
plugin_registry: @plugin_registry,
|
|
180
|
+
dependency_source_index: @dependency_source_index,
|
|
181
|
+
synthetic_method_index: @synthetic_method_index,
|
|
182
|
+
project_patched_methods: @project_patched_methods,
|
|
183
|
+
plugin_prepare_diagnostics: @cached_plugin_prepare_diagnostics.dup.freeze,
|
|
184
|
+
pre_eval_diagnostics: @pre_eval_diagnostics_from_scanner.dup.freeze
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Internal: drives every project-wide pre-pass and stores
|
|
189
|
+
# the results on instance variables in the order the
|
|
190
|
+
# downstream `#run` body expects. Extracted so
|
|
191
|
+
# `#prepare_project_scan` and the prebuilt-less `#run` path
|
|
192
|
+
# share one implementation.
|
|
193
|
+
def run_project_pre_passes(expansion:)
|
|
194
|
+
@plugin_registry = load_plugins
|
|
195
|
+
@dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
|
|
196
|
+
# ADR-18 slice 3 — plugin prepare MUST run before the
|
|
197
|
+
# synthetic-method scanner so cross-plugin facts
|
|
198
|
+
# (`:dry_type_aliases` etc.) are already published when
|
|
199
|
+
# the scanner resolves Tier C `returns_from_arg:`
|
|
200
|
+
# lookups. The diagnostics produced by prepare are
|
|
201
|
+
# captured here so `pre_file_diagnostics` can re-emit
|
|
202
|
+
# them in the existing order without invoking prepare
|
|
203
|
+
# twice. Pool mode still re-runs prepare per worker
|
|
204
|
+
# (workers don't see this early invocation), preserving
|
|
205
|
+
# the existing Phase 4b contract.
|
|
206
|
+
@cached_plugin_prepare_diagnostics =
|
|
207
|
+
pool_mode? ? [] : plugin_prepare_diagnostics
|
|
208
|
+
# ADR-16 slice 2b — Tier C pre-pass. Built once per run
|
|
209
|
+
# against the resolved file set + the loaded plugin
|
|
210
|
+
# registry's `heredoc_templates` so synthetic methods are
|
|
211
|
+
# visible cross-file when per-file inference dispatches.
|
|
212
|
+
@synthetic_method_index = Inference::SyntheticMethodScanner.scan(
|
|
213
|
+
plugin_registry: @plugin_registry,
|
|
214
|
+
paths: expansion.fetch(:files),
|
|
215
|
+
environment: nil,
|
|
216
|
+
fact_store: shared_fact_store,
|
|
217
|
+
buffer: @buffer
|
|
218
|
+
)
|
|
219
|
+
# ADR-17 slice 2 — pre-eval pre-pass. Built once per run
|
|
220
|
+
# from the `pre_eval:` entries that exist on disk
|
|
221
|
+
# (slice-1's `pre-eval.file-not-found` `:error` already
|
|
222
|
+
# surfaced any missing entries; the scanner skips them
|
|
223
|
+
# here). The resulting {ProjectPatchedMethods} registry
|
|
224
|
+
# is consulted by the dispatcher tier between plugins
|
|
225
|
+
# and dependency-source inference so project-side
|
|
226
|
+
# patches resolve cross-file.
|
|
227
|
+
existing_pre_eval = @configuration.pre_eval.select { |path| File.file?(path) }
|
|
228
|
+
pre_eval_outcome = Inference::ProjectPatchedScanner.scan(existing_pre_eval, buffer: @buffer)
|
|
229
|
+
@project_patched_methods = pre_eval_outcome.registry
|
|
230
|
+
@pre_eval_diagnostics_from_scanner = pre_eval_outcome.diagnostics
|
|
231
|
+
# Cross-file class discovery — walks every project file
|
|
232
|
+
# for `class Foo` / `module Bar` declarations so a
|
|
233
|
+
# `Foo.method_call` receiver in one file resolves a
|
|
234
|
+
# `class Foo` declared in a sibling file. Without this
|
|
235
|
+
# pre-pass each file's `discovered_classes` was per-file
|
|
236
|
+
# only, and lexical lookup fell back to stdlib `::Foo`
|
|
237
|
+
# for any user class shadowing a stdlib name (e.g.
|
|
238
|
+
# `Google::Cloud::Storage::File`). Cost is one extra
|
|
239
|
+
# parse pass over the project; small projects pay
|
|
240
|
+
# tens of ms, larger projects ~1s. Future optimisation
|
|
241
|
+
# can share parses with the existing scanner passes.
|
|
242
|
+
@project_discovered_classes =
|
|
243
|
+
Inference::ScopeIndexer.discovered_classes_for_paths(expansion.fetch(:files), buffer: @buffer)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Internal: adopts a frozen {ProjectScan} snapshot supplied
|
|
247
|
+
# to `Runner.new(prebuilt: ...)` by storing each slot on
|
|
248
|
+
# the runner's ivar surface, mirroring what
|
|
249
|
+
# `run_project_pre_passes` would have produced.
|
|
250
|
+
def adopt_prebuilt_project_scan(scan)
|
|
251
|
+
@plugin_registry = scan.plugin_registry
|
|
252
|
+
@dependency_source_index = scan.dependency_source_index
|
|
253
|
+
@synthetic_method_index = scan.synthetic_method_index
|
|
254
|
+
@project_patched_methods = scan.project_patched_methods
|
|
255
|
+
@cached_plugin_prepare_diagnostics = scan.plugin_prepare_diagnostics
|
|
256
|
+
@pre_eval_diagnostics_from_scanner = scan.pre_eval_diagnostics
|
|
257
|
+
end
|
|
258
|
+
private :run_project_pre_passes, :adopt_prebuilt_project_scan
|
|
259
|
+
|
|
260
|
+
# ADR-15 Phase 4b — routes per-file analysis to either the
|
|
261
|
+
# sequential coordinator-side Environment (legacy path,
|
|
262
|
+
# default) or a Ractor worker pool built around
|
|
263
|
+
# {WorkerSession} (opt-in via `workers:`). The sequential
|
|
264
|
+
# path is bit-for-bit unchanged from v0.1.4 / earlier; the
|
|
265
|
+
# pool path is the substrate exercised by phase 4c when
|
|
266
|
+
# `RIGOR_RACTOR_WORKERS` / `.rigor.yml` `parallel.workers:`
|
|
267
|
+
# is wired.
|
|
268
|
+
#
|
|
269
|
+
# Sequential mode also snapshots `class_decl_paths` from the
|
|
270
|
+
# local environment after the per-file loop completes so
|
|
271
|
+
# `RunStats` can attribute the RBS class universe between
|
|
272
|
+
# project-sig and bundled sources. The env stays a LOCAL
|
|
273
|
+
# variable (not an ivar) so it goes GC-eligible when the
|
|
274
|
+
# method returns — holding it as long-lived state added
|
|
275
|
+
# memory pressure that surfaced as a Bus Error during the
|
|
276
|
+
# spec suite under Ruby 4.0 + rbs 4.0.2.
|
|
277
|
+
def analyze_files(files)
|
|
278
|
+
return [] if files.empty?
|
|
279
|
+
|
|
280
|
+
if pool_mode?
|
|
281
|
+
analyze_files_in_pool(files)
|
|
282
|
+
else
|
|
283
|
+
environment = resolve_sequential_environment
|
|
284
|
+
result = files.flat_map { |path| analyze_file(path, environment) }
|
|
285
|
+
if @collect_stats
|
|
286
|
+
loader = environment.rbs_loader
|
|
287
|
+
@class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
|
|
288
|
+
@signature_paths_snapshot = loader&.signature_paths || [].freeze
|
|
289
|
+
end
|
|
290
|
+
result
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Sequential-mode environment resolver. Returns the supplied
|
|
295
|
+
# `environment:` override (with the runner's fresh per-run
|
|
296
|
+
# reporter pair attached so dispatcher events route to THIS
|
|
297
|
+
# runner's diagnostics) when present; otherwise builds a
|
|
298
|
+
# fresh Environment per-call via {#build_runner_environment}
|
|
299
|
+
# — preserving the pre-override behaviour bit-for-bit.
|
|
300
|
+
def resolve_sequential_environment
|
|
301
|
+
return build_runner_environment unless @environment_override
|
|
302
|
+
|
|
303
|
+
@environment_override.attach_reporters!(
|
|
304
|
+
rbs_extended_reporter: @rbs_extended_reporter,
|
|
305
|
+
boundary_cross_reporter: @boundary_cross_reporter
|
|
306
|
+
)
|
|
307
|
+
@environment_override
|
|
85
308
|
end
|
|
309
|
+
private :resolve_sequential_environment
|
|
86
310
|
|
|
87
311
|
# Pre-file diagnostic streams that fire once per run rather
|
|
88
312
|
# than per analyzed file: plugin load / prepare envelopes,
|
|
@@ -90,15 +314,81 @@ module Rigor
|
|
|
90
314
|
# `expand_paths` errors for `paths:` entries that don't
|
|
91
315
|
# exist or aren't `.rb`. Aggregated here so `#run` stays
|
|
92
316
|
# under the ABC budget.
|
|
317
|
+
#
|
|
318
|
+
# ADR-15 Phase 4b — `plugin_prepare_diagnostics` runs on
|
|
319
|
+
# the coordinator's plugin registry under sequential mode;
|
|
320
|
+
# under pool mode each worker re-runs `prepare` against
|
|
321
|
+
# its own plugin instances, so the pool path drains the
|
|
322
|
+
# first worker's prepare-diagnostic snapshot into the
|
|
323
|
+
# aggregated diagnostic stream instead (see
|
|
324
|
+
# {#analyze_files_in_pool}). Skipping the coordinator
|
|
325
|
+
# prepare in pool mode avoids double-running `#prepare`
|
|
326
|
+
# against the coordinator-side plugin instances (which
|
|
327
|
+
# the pool path never consults for per-file analysis).
|
|
93
328
|
def pre_file_diagnostics(expansion)
|
|
329
|
+
# ADR-18 slice 3 — prepare diagnostics are captured
|
|
330
|
+
# earlier in #run (before the synthetic-method scanner)
|
|
331
|
+
# so cross-plugin facts are available to the scanner.
|
|
332
|
+
# We re-surface the captured diagnostics here so the
|
|
333
|
+
# existing pre_file_diagnostics ordering is preserved.
|
|
334
|
+
prepare = pool_mode? ? [] : @cached_plugin_prepare_diagnostics
|
|
94
335
|
plugin_load_diagnostics +
|
|
95
|
-
|
|
336
|
+
prepare +
|
|
337
|
+
pre_eval_diagnostics +
|
|
96
338
|
dependency_source_diagnostics +
|
|
97
339
|
dependency_source_budget_diagnostics +
|
|
98
340
|
dependency_source_config_conflict_diagnostics +
|
|
341
|
+
rbs_coverage_diagnostics +
|
|
99
342
|
expansion.fetch(:errors)
|
|
100
343
|
end
|
|
101
344
|
|
|
345
|
+
# Returns the per-run shared `Plugin::FactStore` instance.
|
|
346
|
+
# All loaded plugins share this store through their
|
|
347
|
+
# respective `Plugin::Services` (the same instance is
|
|
348
|
+
# threaded by `Plugin::Loader.load`). Returns `nil` when
|
|
349
|
+
# no plugins are loaded.
|
|
350
|
+
def shared_fact_store
|
|
351
|
+
return nil if @plugin_registry.nil? || @plugin_registry.empty?
|
|
352
|
+
|
|
353
|
+
@plugin_registry.plugins.first&.services&.fact_store
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# ADR-17 slice 1 — surface a `:error` diagnostic for each
|
|
357
|
+
# `pre_eval:` entry whose resolved path doesn't exist on
|
|
358
|
+
# disk. Loud failure mode (`:error`, not `:warning`):
|
|
359
|
+
# a missing pre_eval path is a configuration mistake the
|
|
360
|
+
# user must fix before analysis is meaningful.
|
|
361
|
+
#
|
|
362
|
+
# Slice 2 adds the `:warning` `pre-eval.parse-error`
|
|
363
|
+
# stream from the pre-pass scanner — accumulated as
|
|
364
|
+
# `@pre_eval_diagnostics_from_scanner` during {#run} and
|
|
365
|
+
# merged here so both diagnostics flow through the same
|
|
366
|
+
# severity / ordering pipeline.
|
|
367
|
+
def pre_eval_diagnostics
|
|
368
|
+
not_found = @configuration.pre_eval.filter_map do |path|
|
|
369
|
+
next if File.file?(path)
|
|
370
|
+
|
|
371
|
+
Diagnostic.new(
|
|
372
|
+
path: ".rigor.yml", line: 1, column: 1,
|
|
373
|
+
message: "pre_eval entry not found: #{path.inspect}. " \
|
|
374
|
+
"Pre-evaluation requires the file to exist on disk; remove the entry " \
|
|
375
|
+
"or create the file before re-running analysis.",
|
|
376
|
+
severity: :error,
|
|
377
|
+
rule: "pre-eval.file-not-found",
|
|
378
|
+
source_family: :builtin
|
|
379
|
+
)
|
|
380
|
+
end
|
|
381
|
+
not_found + Array(@pre_eval_diagnostics_from_scanner).map { |hash| diagnostic_from_hash(hash) }
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def diagnostic_from_hash(hash)
|
|
385
|
+
Diagnostic.new(
|
|
386
|
+
path: hash.fetch(:path), line: hash.fetch(:line), column: hash.fetch(:column),
|
|
387
|
+
message: hash.fetch(:message), severity: hash.fetch(:severity),
|
|
388
|
+
rule: hash.fetch(:rule), source_family: :builtin
|
|
389
|
+
)
|
|
390
|
+
end
|
|
391
|
+
|
|
102
392
|
# `target_ruby` flows through to Prism's `version:` option.
|
|
103
393
|
# Prism enforces the supported range and raises
|
|
104
394
|
# `ArgumentError` for versions it does not recognise. Run a
|
|
@@ -120,6 +410,291 @@ module Rigor
|
|
|
120
410
|
|
|
121
411
|
private
|
|
122
412
|
|
|
413
|
+
# Editor mode § "Scope choice — option A". Under
|
|
414
|
+
# `buffer:` non-nil the per-file analysis emits diagnostics
|
|
415
|
+
# ONLY for the buffer's logical path; the rest of `paths:`
|
|
416
|
+
# is consumed by the project-wide pre-passes (synthetic
|
|
417
|
+
# methods, project-patched methods, plugin facts) but
|
|
418
|
+
# contributes no per-file diagnostics. This is the v1 cut
|
|
419
|
+
# before a per-file diagnostic cache exists; option B (full
|
|
420
|
+
# project + incremental cache) is queued.
|
|
421
|
+
#
|
|
422
|
+
# The buffer's logical path is added to the file list even
|
|
423
|
+
# if it's not under `paths:` — per design § "Failure
|
|
424
|
+
# envelope": "--instead-of=Y with Y not under any paths:
|
|
425
|
+
# directory → treated as a valid logical identity for the
|
|
426
|
+
# buffer".
|
|
427
|
+
def target_files(expansion)
|
|
428
|
+
files = expansion.fetch(:files)
|
|
429
|
+
return files if @buffer.nil?
|
|
430
|
+
|
|
431
|
+
[@buffer.logical_path]
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Editor mode (`buffer:` non-nil) auto-flips the cache store
|
|
435
|
+
# to `read_only: true` so multiple debounced editor invocations
|
|
436
|
+
# against the same project don't churn the on-disk cache or
|
|
437
|
+
# race on schema-version writes. Cache reads continue
|
|
438
|
+
# unchanged; misses still run the producer block but the
|
|
439
|
+
# result is not persisted. Per design doc § "Cache behaviour".
|
|
440
|
+
def enforce_read_only_cache(cache_store, buffer)
|
|
441
|
+
return cache_store if buffer.nil?
|
|
442
|
+
return cache_store if cache_store.nil?
|
|
443
|
+
return cache_store if cache_store.read_only?
|
|
444
|
+
|
|
445
|
+
Cache::Store.new(root: cache_store.root, read_only: true)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# ADR-15 Phase 4b — pool mode is enabled when `@workers > 0`.
|
|
449
|
+
# Editor mode (`buffer:` non-nil) silently overrides pool
|
|
450
|
+
# mode to sequential: per design § "Ractor pool mode", the
|
|
451
|
+
# pool's warm-up cost dominates one-file wall time, so the
|
|
452
|
+
# pool gains nothing on a per-buffer invocation. The override
|
|
453
|
+
# is part of the contract — not a degradation diagnostic —
|
|
454
|
+
# because `--workers=N` is a project-scale knob and editor
|
|
455
|
+
# mode is per-buffer; the conflict resolves toward the more
|
|
456
|
+
# specific axis.
|
|
457
|
+
def pool_mode?
|
|
458
|
+
return false unless @workers.is_a?(Integer) && @workers.positive?
|
|
459
|
+
|
|
460
|
+
@buffer.nil?
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Coordinator-side Environment used by the sequential code
|
|
464
|
+
# path. Pool mode builds one Environment per worker inside
|
|
465
|
+
# the worker Ractor's body instead.
|
|
466
|
+
def build_runner_environment
|
|
467
|
+
Environment.for_project(
|
|
468
|
+
libraries: @configuration.libraries,
|
|
469
|
+
signature_paths: @configuration.signature_paths,
|
|
470
|
+
cache_store: @cache_store,
|
|
471
|
+
plugin_registry: @plugin_registry,
|
|
472
|
+
dependency_source_index: @dependency_source_index,
|
|
473
|
+
rbs_extended_reporter: @rbs_extended_reporter,
|
|
474
|
+
boundary_cross_reporter: @boundary_cross_reporter,
|
|
475
|
+
bundler_bundle_path: @configuration.bundler_bundle_path,
|
|
476
|
+
bundler_auto_detect: @configuration.bundler_auto_detect,
|
|
477
|
+
bundler_lockfile: @configuration.bundler_lockfile,
|
|
478
|
+
rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
|
|
479
|
+
rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect,
|
|
480
|
+
synthetic_method_index: @synthetic_method_index,
|
|
481
|
+
project_patched_methods: @project_patched_methods
|
|
482
|
+
)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# ADR-15 Phase 4b — Ractor pool around {WorkerSession}.
|
|
486
|
+
# Spawns `@workers` Ractors; each takes the shareable
|
|
487
|
+
# payload (Configuration, cache_root String, plugin
|
|
488
|
+
# Blueprint Array, explain Boolean) and builds its OWN
|
|
489
|
+
# WorkerSession internally. Files are distributed
|
|
490
|
+
# round-robin across the pool; each worker writes back to
|
|
491
|
+
# the main Ractor's mailbox via `Ractor.main.send` with
|
|
492
|
+
# one of three message kinds:
|
|
493
|
+
#
|
|
494
|
+
# - `[:prepare, diagnostics]` — once at startup, the
|
|
495
|
+
# session's `prepare_diagnostics` snapshot. The
|
|
496
|
+
# coordinator keeps the FIRST worker's snapshot only
|
|
497
|
+
# (plugin `#prepare` is deterministic per plugin, so
|
|
498
|
+
# each worker produces the same diagnostic set; surfacing
|
|
499
|
+
# them once avoids N× duplication).
|
|
500
|
+
# - `[:file, path, diagnostics]` — one per analysed file.
|
|
501
|
+
# - `[:done, drained_reporters]` — once at exit, the
|
|
502
|
+
# per-worker reporter snapshots for end-of-pool merge.
|
|
503
|
+
#
|
|
504
|
+
# The Ruby 4.0+ Ractor model uses a single per-Ractor
|
|
505
|
+
# mailbox (no `Ractor.yield`); workers push back via
|
|
506
|
+
# `Ractor.main.send`. The coordinator drains its mailbox
|
|
507
|
+
# via `Ractor.receive` until it has counted exactly
|
|
508
|
+
# `pool.size` `:done` messages.
|
|
509
|
+
#
|
|
510
|
+
# Diagnostic order: original path order. Workers may
|
|
511
|
+
# complete files out of order; the coordinator re-orders
|
|
512
|
+
# via the `results_by_path` Hash before flattening.
|
|
513
|
+
#
|
|
514
|
+
# Reporter merge: per-worker `RbsExtended::Reporter` and
|
|
515
|
+
# `BoundaryCrossReporter` entries are replayed into the
|
|
516
|
+
# runner-side accumulators via their `record_*` APIs,
|
|
517
|
+
# which dedupe on the same keys as a single-session run
|
|
518
|
+
# would. Net result: reporter state is identical to the
|
|
519
|
+
# sequential path.
|
|
520
|
+
def analyze_files_in_pool(files) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity
|
|
521
|
+
# Pre-warm class-level lazy memos on the MAIN Ractor.
|
|
522
|
+
# `Environment::ClassRegistry.default` is the
|
|
523
|
+
# default kwarg threaded through `Environment.new`
|
|
524
|
+
# inside each worker session; lazy-initialising it
|
|
525
|
+
# from a non-main Ractor would trip
|
|
526
|
+
# `Ractor::IsolationError`. Touching it here forces
|
|
527
|
+
# the (shareable) registry into the class-ivar cache
|
|
528
|
+
# before any worker reads.
|
|
529
|
+
Environment::ClassRegistry.default
|
|
530
|
+
|
|
531
|
+
# ADR-15 Phase 4b.x — pre-warm the RBS cache so
|
|
532
|
+
# workers serve every reflection query from the
|
|
533
|
+
# Marshal blob on disk. Without this, the first
|
|
534
|
+
# cache MISS inside a worker falls through to
|
|
535
|
+
# `RBS::EnvironmentLoader.new`, which reads a chain
|
|
536
|
+
# of non-`Ractor.shareable?` RubyGems / RBS module
|
|
537
|
+
# constants and raises `Ractor::IsolationError`.
|
|
538
|
+
# Pre-warming requires a `cache_store`; the run aborts
|
|
539
|
+
# to sequential mode otherwise. See ADR-15 Phase 4b.x
|
|
540
|
+
# for the full chain of failing constants.
|
|
541
|
+
if @cache_store.nil?
|
|
542
|
+
return analyze_files_sequentially_fallback(
|
|
543
|
+
files, reason: "pool mode requires a cache_store (--no-cache disables pool)"
|
|
544
|
+
)
|
|
545
|
+
end
|
|
546
|
+
prewarm_rbs_cache_for_pool
|
|
547
|
+
|
|
548
|
+
configuration = @configuration
|
|
549
|
+
cache_root = @cache_store&.root
|
|
550
|
+
blueprints = @plugin_registry.blueprints
|
|
551
|
+
explain = @explain
|
|
552
|
+
|
|
553
|
+
pool = Array.new(@workers) do
|
|
554
|
+
Ractor.new(configuration, cache_root, blueprints, explain) do |configuration, cache_root, blueprints, explain|
|
|
555
|
+
cache_store = cache_root ? Rigor::Cache::Store.new(root: cache_root) : nil
|
|
556
|
+
session = Rigor::Analysis::WorkerSession.new(
|
|
557
|
+
configuration: configuration,
|
|
558
|
+
cache_store: cache_store,
|
|
559
|
+
plugin_blueprints: blueprints,
|
|
560
|
+
explain: explain
|
|
561
|
+
)
|
|
562
|
+
main = Ractor.main
|
|
563
|
+
main.send([:prepare, session.prepare_diagnostics])
|
|
564
|
+
|
|
565
|
+
loop do
|
|
566
|
+
msg = Ractor.receive
|
|
567
|
+
break if msg.nil?
|
|
568
|
+
|
|
569
|
+
main.send([:file, msg, session.analyze(msg)])
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
main.send([:done, session.drain_reporters])
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
files.each_with_index { |path, index| pool[index % pool.size].send(path) }
|
|
577
|
+
pool.each { |worker| worker.send(nil) }
|
|
578
|
+
|
|
579
|
+
prepare_diagnostics = nil
|
|
580
|
+
results_by_path = {}
|
|
581
|
+
done_count = 0
|
|
582
|
+
|
|
583
|
+
while done_count < pool.size
|
|
584
|
+
message = Ractor.receive
|
|
585
|
+
case message.first
|
|
586
|
+
when :prepare
|
|
587
|
+
prepare_diagnostics ||= message.last
|
|
588
|
+
when :file
|
|
589
|
+
results_by_path[message[1]] = message[2]
|
|
590
|
+
when :done
|
|
591
|
+
merge_worker_reporters(message.last)
|
|
592
|
+
done_count += 1
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
pool.each(&:join)
|
|
597
|
+
|
|
598
|
+
Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# End-of-run telemetry. Walks the cached
|
|
602
|
+
# `class_decl_paths` snapshot (sequential mode: from
|
|
603
|
+
# the coordinator's environment; pool mode: from the
|
|
604
|
+
# first worker's `:prepare` payload) and partitions the
|
|
605
|
+
# RBS class universe into "project sig/" (paths under
|
|
606
|
+
# `signature_paths`) vs "bundled" (everything else).
|
|
607
|
+
# Gem source-walk counts come from `dependency_source_index`
|
|
608
|
+
# which is already constructed regardless of pool mode.
|
|
609
|
+
# Wall + RSS are single syscalls; total cost is bounded
|
|
610
|
+
# by the snapshot size (~1000-2000 entries).
|
|
611
|
+
def build_run_stats(wall_started_at:, expansion:)
|
|
612
|
+
snapshot = @class_decl_paths_snapshot
|
|
613
|
+
project_sig, bundled = RunStats.partition_classes(
|
|
614
|
+
class_decl_paths: snapshot,
|
|
615
|
+
signature_paths: @signature_paths_snapshot
|
|
616
|
+
)
|
|
617
|
+
RunStats.new(
|
|
618
|
+
wall_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_started_at,
|
|
619
|
+
peak_rss_bytes: RunStats.peak_rss_bytes,
|
|
620
|
+
target_files: expansion.fetch(:files).size,
|
|
621
|
+
rbs_classes_total: snapshot.size,
|
|
622
|
+
rbs_classes_project_sig: project_sig,
|
|
623
|
+
rbs_classes_bundled: bundled,
|
|
624
|
+
rbs_attribution_available: RunStats.attribution_available?(class_decl_paths: snapshot),
|
|
625
|
+
gem_walk_classes: @dependency_source_index.class_to_gem.size,
|
|
626
|
+
gem_walk_gems: @dependency_source_index.resolved_gems.size
|
|
627
|
+
)
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# ADR-15 Phase 4b.x — drives every cached RBS producer
|
|
631
|
+
# on the main Ractor so each worker can serve all
|
|
632
|
+
# reflection queries from disk (Marshal-load only).
|
|
633
|
+
# Builds a single coordinator-side {Environment} for
|
|
634
|
+
# this purpose; the env object is discarded immediately
|
|
635
|
+
# after the cache is warm — workers build their own
|
|
636
|
+
# `Environment.for_project` inside the Ractor body,
|
|
637
|
+
# which then routes through `cached_env` instead of
|
|
638
|
+
# `RBS::EnvironmentLoader.new`.
|
|
639
|
+
def prewarm_rbs_cache_for_pool
|
|
640
|
+
warm_env = Environment.for_project(
|
|
641
|
+
libraries: @configuration.libraries,
|
|
642
|
+
signature_paths: @configuration.signature_paths,
|
|
643
|
+
cache_store: @cache_store,
|
|
644
|
+
bundler_bundle_path: @configuration.bundler_bundle_path,
|
|
645
|
+
bundler_auto_detect: @configuration.bundler_auto_detect,
|
|
646
|
+
bundler_lockfile: @configuration.bundler_lockfile,
|
|
647
|
+
rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
|
|
648
|
+
rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect
|
|
649
|
+
)
|
|
650
|
+
warm_env.rbs_loader&.prewarm
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# ADR-15 Phase 4b.x — pool-mode safety net. When pool
|
|
654
|
+
# mode is configured but a precondition fails (currently:
|
|
655
|
+
# `--no-cache` would force workers through
|
|
656
|
+
# `EnvironmentLoader.new`), degrade to sequential
|
|
657
|
+
# analysis with a `:warning` `pool-degraded` diagnostic
|
|
658
|
+
# at run start. The actual per-file analysis runs on
|
|
659
|
+
# the coordinator, identical to the default sequential
|
|
660
|
+
# path.
|
|
661
|
+
def analyze_files_sequentially_fallback(files, reason:)
|
|
662
|
+
environment = build_runner_environment
|
|
663
|
+
diagnostics = files.flat_map { |path| analyze_file(path, environment) }
|
|
664
|
+
loader = environment.rbs_loader
|
|
665
|
+
@class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
|
|
666
|
+
@signature_paths_snapshot = loader&.signature_paths || [].freeze
|
|
667
|
+
diagnostics.unshift(
|
|
668
|
+
Diagnostic.new(
|
|
669
|
+
path: ".rigor.yml", line: 1, column: 1,
|
|
670
|
+
message: "pool mode degraded to sequential: #{reason}",
|
|
671
|
+
severity: :warning, rule: "pool-degraded", source_family: :builtin
|
|
672
|
+
)
|
|
673
|
+
)
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def merge_worker_reporters(drained)
|
|
677
|
+
rbs = drained.fetch(:rbs_extended)
|
|
678
|
+
rbs.fetch(:unresolved_payloads).each do |entry|
|
|
679
|
+
@rbs_extended_reporter.record_unresolved(
|
|
680
|
+
payload: entry.payload, source_location: entry.source_location
|
|
681
|
+
)
|
|
682
|
+
end
|
|
683
|
+
rbs.fetch(:lossy_projections).each do |entry|
|
|
684
|
+
@rbs_extended_reporter.record_lossy_projection(
|
|
685
|
+
head: entry.head, source_location: entry.source_location
|
|
686
|
+
)
|
|
687
|
+
end
|
|
688
|
+
drained.fetch(:boundary_cross).each do |entry|
|
|
689
|
+
@boundary_cross_reporter.record(
|
|
690
|
+
class_name: entry.class_name,
|
|
691
|
+
method_name: entry.method_name,
|
|
692
|
+
gem_name: entry.gem_name,
|
|
693
|
+
rbs_display: entry.rbs_display
|
|
694
|
+
)
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
|
|
123
698
|
# Loads project-configured plugins through {Rigor::Plugin::Loader}
|
|
124
699
|
# and returns the resulting {Rigor::Plugin::Registry}. Loader
|
|
125
700
|
# failures are isolated: each surfaces as a `:plugin_loader`
|
|
@@ -304,6 +879,70 @@ module Rigor
|
|
|
304
879
|
end
|
|
305
880
|
end
|
|
306
881
|
|
|
882
|
+
# O4 Layer 3 slice 3 — graceful-degradation coverage
|
|
883
|
+
# report. When the project has a `Gemfile.lock` (slice 1)
|
|
884
|
+
# and one or more locked gems are not covered by ANY of
|
|
885
|
+
# the four RBS resolution paths (`DEFAULT_LIBRARIES`,
|
|
886
|
+
# `data/vendored_gem_sigs/`, slice-1 bundle-shipped
|
|
887
|
+
# `sig/`, slice-2 `rbs_collection.lock.yaml`), emit a
|
|
888
|
+
# single `:info` diagnostic summarising the uncovered set
|
|
889
|
+
# so the user can act on it (run `rbs collection install`,
|
|
890
|
+
# opt the gem into `dependencies.source_inference:`, or
|
|
891
|
+
# accept the `Dynamic[T]` fallback).
|
|
892
|
+
#
|
|
893
|
+
# Suppressed when the lockfile is empty, when every gem
|
|
894
|
+
# is covered, or when slice 1's `bundler.lockfile`
|
|
895
|
+
# discovery returned nothing (no lockfile to read).
|
|
896
|
+
def rbs_coverage_diagnostics
|
|
897
|
+
locked = Environment::LockfileResolver.locked_gems(
|
|
898
|
+
lockfile_path: @configuration.bundler_lockfile,
|
|
899
|
+
project_root: Dir.pwd,
|
|
900
|
+
auto_detect: @configuration.bundler_auto_detect
|
|
901
|
+
)
|
|
902
|
+
return [] if locked.empty?
|
|
903
|
+
|
|
904
|
+
bundle_sig_paths = Environment::BundleSigDiscovery.discover(
|
|
905
|
+
bundle_path: @configuration.bundler_bundle_path,
|
|
906
|
+
project_root: Dir.pwd,
|
|
907
|
+
auto_detect: @configuration.bundler_auto_detect,
|
|
908
|
+
locked_gems: locked
|
|
909
|
+
)
|
|
910
|
+
collection_paths = Environment::RbsCollectionDiscovery.discover(
|
|
911
|
+
lockfile_path: @configuration.rbs_collection_lockfile,
|
|
912
|
+
project_root: Dir.pwd,
|
|
913
|
+
auto_detect: @configuration.rbs_collection_auto_detect
|
|
914
|
+
)
|
|
915
|
+
rows = Environment::RbsCoverageReport.classify(
|
|
916
|
+
locked_gems: locked,
|
|
917
|
+
default_libraries: Environment::DEFAULT_LIBRARIES,
|
|
918
|
+
bundle_sig_paths: bundle_sig_paths,
|
|
919
|
+
rbs_collection_paths: collection_paths
|
|
920
|
+
)
|
|
921
|
+
missing = Environment::RbsCoverageReport.missing(rows)
|
|
922
|
+
return [] if missing.empty?
|
|
923
|
+
|
|
924
|
+
[build_rbs_coverage_missing_diagnostic(missing)]
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
def build_rbs_coverage_missing_diagnostic(missing)
|
|
928
|
+
sample_size = 5
|
|
929
|
+
sample = missing.first(sample_size).map(&:gem_name)
|
|
930
|
+
suffix = missing.size > sample_size ? ", and #{missing.size - sample_size} more" : ""
|
|
931
|
+
Diagnostic.new(
|
|
932
|
+
path: ".rigor.yml",
|
|
933
|
+
line: 1,
|
|
934
|
+
column: 1,
|
|
935
|
+
message: "#{missing.size} gem(s) in Gemfile.lock have no RBS available: " \
|
|
936
|
+
"#{sample.join(', ')}#{suffix}. " \
|
|
937
|
+
"Consider `rbs collection install` to fetch community RBS from " \
|
|
938
|
+
"`ruby/gem_rbs_collection`, ship `sig/` in the gem itself, or " \
|
|
939
|
+
"opt the gem into `dependencies.source_inference:` in `.rigor.yml`.",
|
|
940
|
+
severity: :info,
|
|
941
|
+
rule: "rbs.coverage.missing-gem",
|
|
942
|
+
source_family: :builtin
|
|
943
|
+
)
|
|
944
|
+
end
|
|
945
|
+
|
|
307
946
|
# ADR-13 slice 3b — drains the per-run
|
|
308
947
|
# {RbsExtended::Reporter} into one diagnostic per accumulated
|
|
309
948
|
# event:
|
|
@@ -520,7 +1159,11 @@ module Rigor
|
|
|
520
1159
|
Array(paths).each do |path|
|
|
521
1160
|
if File.directory?(path)
|
|
522
1161
|
files.concat(reject_excluded(Dir.glob(File.join(path, RUBY_GLOB))))
|
|
523
|
-
|
|
1162
|
+
# Editor-mode bypass: the buffer's logical path is treated
|
|
1163
|
+
# as a real `.rb` file regardless of on-disk presence —
|
|
1164
|
+
# `parse_source` reads bytes from the buffer's physical
|
|
1165
|
+
# path. Common case: LSP client editing a brand-new file.
|
|
1166
|
+
elsif accept_as_ruby_file?(path)
|
|
524
1167
|
files << path
|
|
525
1168
|
elsif File.exist?(path)
|
|
526
1169
|
errors << path_error(path, "not a Ruby file (expected `.rb` or a directory)")
|
|
@@ -531,6 +1174,11 @@ module Rigor
|
|
|
531
1174
|
{ files: files, errors: errors }
|
|
532
1175
|
end
|
|
533
1176
|
|
|
1177
|
+
def accept_as_ruby_file?(path)
|
|
1178
|
+
(File.file?(path) && path.end_with?(".rb")) ||
|
|
1179
|
+
(@buffer && path == @buffer.logical_path)
|
|
1180
|
+
end
|
|
1181
|
+
|
|
534
1182
|
# `Configuration#exclude_patterns` is a list of glob patterns
|
|
535
1183
|
# checked against each globbed path via `File.fnmatch?` (without
|
|
536
1184
|
# `FNM_PATHNAME`, so `**` and `*` both span path separators —
|
|
@@ -560,11 +1208,25 @@ module Rigor
|
|
|
560
1208
|
)
|
|
561
1209
|
end
|
|
562
1210
|
|
|
1211
|
+
# Reads + parses the source at `path`. Under editor mode
|
|
1212
|
+
# (`@buffer` set) reads bytes from `@buffer.physical_path`
|
|
1213
|
+
# when `path` matches the logical binding, then parses with
|
|
1214
|
+
# `filepath: path` so Prism's location data carries the
|
|
1215
|
+
# LOGICAL path. Non-binding paths go through the cheaper
|
|
1216
|
+
# `Prism.parse_file` codepath unchanged.
|
|
1217
|
+
def parse_source(path)
|
|
1218
|
+
physical = @buffer ? @buffer.resolve(path) : path
|
|
1219
|
+
return Prism.parse_file(physical, version: @configuration.target_ruby) if physical == path
|
|
1220
|
+
|
|
1221
|
+
Prism.parse(File.read(physical), filepath: path, version: @configuration.target_ruby)
|
|
1222
|
+
end
|
|
1223
|
+
|
|
563
1224
|
def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
|
|
564
|
-
parse_result =
|
|
1225
|
+
parse_result = parse_source(path)
|
|
565
1226
|
return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
|
|
566
1227
|
|
|
567
1228
|
scope = Scope.empty(environment: environment, source_path: path)
|
|
1229
|
+
scope = scope.with_discovered_classes(@project_discovered_classes) unless @project_discovered_classes.empty?
|
|
568
1230
|
index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
|
|
569
1231
|
diagnostics = CheckRules.diagnose(
|
|
570
1232
|
path: path,
|