rigortype 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
  4. data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
  5. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +18 -1
  6. data/lib/rigor/analysis/check_rules.rb +34 -6
  7. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  8. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  9. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  10. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  11. data/lib/rigor/analysis/runner.rb +160 -1190
  12. data/lib/rigor/analysis/worker_session.rb +47 -8
  13. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  14. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  15. data/lib/rigor/cache/store.rb +46 -13
  16. data/lib/rigor/cli/check_command.rb +705 -0
  17. data/lib/rigor/cli/ci_detector.rb +94 -0
  18. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  19. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  20. data/lib/rigor/cli/trace_command.rb +143 -0
  21. data/lib/rigor/cli/trace_renderer.rb +310 -0
  22. data/lib/rigor/cli.rb +15 -614
  23. data/lib/rigor/configuration.rb +9 -6
  24. data/lib/rigor/environment/rbs_loader.rb +53 -68
  25. data/lib/rigor/environment.rb +1 -1
  26. data/lib/rigor/inference/acceptance.rb +10 -0
  27. data/lib/rigor/inference/expression_typer.rb +28 -62
  28. data/lib/rigor/inference/flow_tracer.rb +180 -0
  29. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  31. data/lib/rigor/inference/method_dispatcher.rb +115 -54
  32. data/lib/rigor/inference/narrowing.rb +60 -0
  33. data/lib/rigor/inference/scope_indexer.rb +75 -15
  34. data/lib/rigor/inference/statement_evaluator.rb +35 -52
  35. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  36. data/lib/rigor/plugin/base.rb +282 -41
  37. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  38. data/lib/rigor/plugin/registry.rb +263 -35
  39. data/lib/rigor/plugin.rb +1 -0
  40. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  41. data/lib/rigor/scope/discovery_index.rb +58 -0
  42. data/lib/rigor/scope.rb +67 -198
  43. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  44. data/lib/rigor/source/literals.rb +14 -0
  45. data/lib/rigor/type/combinator.rb +5 -0
  46. data/lib/rigor/version.rb +1 -1
  47. data/lib/rigor.rb +0 -1
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  49. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  50. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  53. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  54. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  55. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  56. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  57. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  58. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  59. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  60. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  61. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  62. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  63. data/sig/rigor/environment.rbs +0 -2
  64. data/sig/rigor/inference.rbs +5 -0
  65. data/sig/rigor/plugin/base.rbs +1 -2
  66. data/sig/rigor/scope.rbs +41 -29
  67. data/sig/rigor/source.rbs +1 -0
  68. data/skills/rigor-ci-setup/SKILL.md +319 -0
  69. metadata +15 -2
  70. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
@@ -0,0 +1,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../plugin"
4
+ require_relative "../../reflection"
5
+ require_relative "../../type/combinator"
6
+ require_relative "../../inference/scope_indexer"
7
+ require_relative "../../inference/synthetic_method_scanner"
8
+ require_relative "../../inference/project_patched_scanner"
9
+ require_relative "../dependency_source_inference"
10
+ require_relative "../project_scan"
11
+
12
+ module Rigor
13
+ module Analysis
14
+ class Runner
15
+ # Owns every project-wide pre-pass that runs once per run before
16
+ # per-file analysis: plugin load + `#prepare`, the ADR-10
17
+ # dependency-source index, the ADR-16 synthetic-method scanner,
18
+ # the ADR-17 project-patched (`pre_eval:`) scanner, cross-file
19
+ # class discovery, and the ADR-24 def-node + visibility index.
20
+ # Also owns the {ProjectScan} snapshot adopt / build paths the LSP
21
+ # uses.
22
+ #
23
+ # The collaborator produces a frozen {Result} bundle; the {Runner}
24
+ # assigns each slot onto its own ivar surface, preserving the exact
25
+ # assignment order and timing the inline body had. The `pool_mode`
26
+ # predicate is injected (it keys on `@workers` / `@buffer`, owned by
27
+ # the Runner) so this collaborator never calls back into it.
28
+ class ProjectPrePasses
29
+ # Frozen bundle of the project-wide state the pre-passes produce.
30
+ # Mirrors the ivars `run_project_pre_passes` / `adopt_prebuilt`
31
+ # set on the {Runner}, in the same order they were assigned.
32
+ Result = Data.define(
33
+ :plugin_registry,
34
+ :dependency_source_index,
35
+ :cached_plugin_prepare_diagnostics,
36
+ :synthetic_method_index,
37
+ :project_patched_methods,
38
+ :pre_eval_diagnostics_from_scanner,
39
+ :discovered_classes,
40
+ :discovered_def_nodes,
41
+ :discovered_def_sources,
42
+ :discovered_superclasses,
43
+ :discovered_includes,
44
+ :discovered_class_sources,
45
+ :discovered_method_visibilities,
46
+ :discovered_methods,
47
+ :data_member_layouts
48
+ )
49
+
50
+ # @param configuration [Rigor::Configuration]
51
+ # @param cache_store [Rigor::Cache::Store, nil]
52
+ # @param buffer [BufferBinding, nil]
53
+ # @param plugin_requirer [#call, nil]
54
+ # @param pool_mode [#call] reader returning the pool-mode flag.
55
+ def initialize(configuration:, cache_store:, buffer:, plugin_requirer:, pool_mode:)
56
+ @configuration = configuration
57
+ @cache_store = cache_store
58
+ @buffer = buffer
59
+ @plugin_requirer = plugin_requirer
60
+ @pool_mode_reader = pool_mode
61
+ end
62
+
63
+ # Internal: drives every project-wide pre-pass and returns the
64
+ # results bundled in {Result} in the order the downstream `#run`
65
+ # body expects. Extracted so `#prepare_project_scan` and the
66
+ # prebuilt-less `#run` path share one implementation.
67
+ def run(expansion:) # rubocop:disable Metrics/MethodLength
68
+ plugin_registry = load_plugins
69
+ dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
70
+ # ADR-18 slice 3 — plugin prepare MUST run before the
71
+ # synthetic-method scanner so cross-plugin facts
72
+ # (`:dry_type_aliases` etc.) are already published when
73
+ # the scanner resolves Tier C `returns_from_arg:`
74
+ # lookups. The diagnostics produced by prepare are
75
+ # captured here so `pre_file_diagnostics` can re-emit
76
+ # them in the existing order without invoking prepare
77
+ # twice. Pool mode still re-runs prepare per worker
78
+ # (workers don't see this early invocation), preserving
79
+ # the existing Phase 4b contract.
80
+ cached_plugin_prepare_diagnostics =
81
+ pool_mode? ? [] : plugin_prepare_diagnostics(plugin_registry)
82
+ # ADR-16 slice 2b — Tier C pre-pass. Built once per run
83
+ # against the resolved file set + the loaded plugin
84
+ # registry's `heredoc_templates` so synthetic methods are
85
+ # visible cross-file when per-file inference dispatches.
86
+ synthetic_method_index = Inference::SyntheticMethodScanner.scan(
87
+ plugin_registry: plugin_registry,
88
+ paths: expansion.fetch(:files),
89
+ environment: nil,
90
+ fact_store: shared_fact_store(plugin_registry),
91
+ buffer: @buffer
92
+ )
93
+ # ADR-17 slice 2 — pre-eval pre-pass. Built once per run
94
+ # from the `pre_eval:` entries that exist on disk
95
+ # (slice-1's `pre-eval.file-not-found` `:error` already
96
+ # surfaced any missing entries; the scanner skips them
97
+ # here). The resulting {ProjectPatchedMethods} registry
98
+ # is consulted by the dispatcher tier between plugins
99
+ # and dependency-source inference so project-side
100
+ # patches resolve cross-file.
101
+ existing_pre_eval = @configuration.pre_eval.select { |path| File.file?(path) }
102
+ pre_eval_outcome = Inference::ProjectPatchedScanner.scan(existing_pre_eval, buffer: @buffer)
103
+ project_patched_methods = pre_eval_outcome.registry
104
+ pre_eval_diagnostics_from_scanner = pre_eval_outcome.diagnostics
105
+ # Cross-file class discovery — walks every project file
106
+ # for `class Foo` / `module Bar` declarations so a
107
+ # `Foo.method_call` receiver in one file resolves a
108
+ # `class Foo` declared in a sibling file. Without this
109
+ # pre-pass each file's `discovered_classes` was per-file
110
+ # only, and lexical lookup fell back to stdlib `::Foo`
111
+ # for any user class shadowing a stdlib name (e.g.
112
+ # `Google::Cloud::Storage::File`). Cost is one extra
113
+ # parse pass over the project; small projects pay
114
+ # tens of ms, larger projects ~1s. Future optimisation
115
+ # can share parses with the existing scanner passes.
116
+ discovered_classes =
117
+ Inference::ScopeIndexer.discovered_classes_for_paths(expansion.fetch(:files), buffer: @buffer)
118
+ # ADR-24 slice 2 — cross-file def-node + class->superclass
119
+ # index so an implicit-self call inside a subclass
120
+ # resolves a superclass `def` declared in a sibling
121
+ # file. One extra parse pass over the project; shares
122
+ # the cost profile of the class-discovery pass above.
123
+ def_index =
124
+ Inference::ScopeIndexer.discovered_def_index_for_paths(expansion.fetch(:files), buffer: @buffer)
125
+ Result.new(
126
+ plugin_registry: plugin_registry,
127
+ dependency_source_index: dependency_source_index,
128
+ cached_plugin_prepare_diagnostics: cached_plugin_prepare_diagnostics,
129
+ synthetic_method_index: synthetic_method_index,
130
+ project_patched_methods: project_patched_methods,
131
+ pre_eval_diagnostics_from_scanner: pre_eval_diagnostics_from_scanner,
132
+ discovered_classes: discovered_classes,
133
+ discovered_def_nodes: def_index.fetch(:def_nodes),
134
+ discovered_def_sources: def_index.fetch(:def_sources),
135
+ discovered_superclasses: def_index.fetch(:superclasses),
136
+ discovered_includes: def_index.fetch(:includes),
137
+ discovered_class_sources: def_index.fetch(:class_sources),
138
+ discovered_method_visibilities: def_index.fetch(:method_visibilities),
139
+ discovered_methods: def_index.fetch(:methods),
140
+ data_member_layouts: def_index.fetch(:data_member_layouts)
141
+ )
142
+ end
143
+
144
+ # Builds the LSP-facing {ProjectScan} snapshot from a fresh
145
+ # pre-pass run. The runner adopts `result` onto its ivars first
146
+ # so the same registry object that ran `#prepare` (and so the
147
+ # populated `services.fact_store`) is the one the snapshot
148
+ # carries.
149
+ def build_project_scan(result)
150
+ ProjectScan.new(
151
+ plugin_registry: result.plugin_registry,
152
+ dependency_source_index: result.dependency_source_index,
153
+ synthetic_method_index: result.synthetic_method_index,
154
+ project_patched_methods: result.project_patched_methods,
155
+ plugin_prepare_diagnostics: result.cached_plugin_prepare_diagnostics.dup.freeze,
156
+ pre_eval_diagnostics: result.pre_eval_diagnostics_from_scanner.dup.freeze
157
+ )
158
+ end
159
+
160
+ # Translates a prebuilt {ProjectScan} snapshot supplied to
161
+ # `Runner.new(prebuilt: ...)` into a {Result} the runner adopts
162
+ # the same way it adopts a fresh pre-pass run. The discovery
163
+ # tables are not part of the snapshot (the LSP path seeds an
164
+ # empty project scope), so they stay at their frozen-empty
165
+ # constructor defaults.
166
+ def adopt_prebuilt(scan)
167
+ Result.new(
168
+ plugin_registry: scan.plugin_registry,
169
+ dependency_source_index: scan.dependency_source_index,
170
+ cached_plugin_prepare_diagnostics: scan.plugin_prepare_diagnostics,
171
+ synthetic_method_index: scan.synthetic_method_index,
172
+ project_patched_methods: scan.project_patched_methods,
173
+ pre_eval_diagnostics_from_scanner: scan.pre_eval_diagnostics,
174
+ discovered_classes: nil,
175
+ discovered_def_nodes: nil,
176
+ discovered_def_sources: nil,
177
+ discovered_superclasses: nil,
178
+ discovered_includes: nil,
179
+ discovered_class_sources: nil,
180
+ discovered_method_visibilities: nil,
181
+ discovered_methods: nil,
182
+ data_member_layouts: nil
183
+ )
184
+ end
185
+
186
+ # Returns the per-run shared `Plugin::FactStore` instance.
187
+ # All loaded plugins share this store through their
188
+ # respective `Plugin::Services` (the same instance is
189
+ # threaded by `Plugin::Loader.load`). Returns `nil` when
190
+ # no plugins are loaded.
191
+ def shared_fact_store(plugin_registry)
192
+ return nil if plugin_registry.nil? || plugin_registry.empty?
193
+
194
+ plugin_registry.plugins.first&.services&.fact_store
195
+ end
196
+
197
+ private
198
+
199
+ def pool_mode?
200
+ @pool_mode_reader.call
201
+ end
202
+
203
+ # Loads project-configured plugins through {Rigor::Plugin::Loader}
204
+ # and returns the resulting {Rigor::Plugin::Registry}. Loader
205
+ # failures are isolated: each surfaces as a `:plugin_loader`
206
+ # diagnostic on the run's `Result` rather than aborting the
207
+ # analysis. Plugins that load successfully but contribute no
208
+ # protocol hooks are inert in slice 1; later v0.1.0 slices
209
+ # wire the contribution merger through this registry.
210
+ def load_plugins
211
+ return Plugin::Registry::EMPTY if @configuration.plugins.empty?
212
+
213
+ services = Plugin::Services.new(
214
+ reflection: Reflection,
215
+ type: Type::Combinator,
216
+ configuration: @configuration,
217
+ cache_store: @cache_store,
218
+ trust_policy: build_trust_policy
219
+ )
220
+ if @plugin_requirer
221
+ Plugin::Loader.load(configuration: @configuration, services: services, requirer: @plugin_requirer)
222
+ else
223
+ Plugin::Loader.load(configuration: @configuration, services: services)
224
+ end
225
+ end
226
+
227
+ # Builds the {Rigor::Plugin::TrustPolicy} for this run. Trusted
228
+ # gems are the gem-name half of every entry in
229
+ # `Configuration#plugins`. Allowed read roots default to the
230
+ # project root (CWD), the project's signature_paths, and each
231
+ # trusted gem's `Gem::Specification#full_gem_path`, plus any
232
+ # extras the user listed under `plugins_io.allowed_paths`.
233
+ # Slice 2 keeps `network_policy` `:disabled` — the only value
234
+ # the configuration accepts today.
235
+ def build_trust_policy
236
+ trusted_gems = @configuration.plugins.map { |entry| trusted_gem_name(entry) }.uniq
237
+ roots = [Dir.pwd]
238
+ Array(@configuration.signature_paths).each { |sp| roots << File.expand_path(sp) }
239
+ trusted_gems.each do |gem_name|
240
+ path = trusted_gem_root(gem_name)
241
+ roots << path if path
242
+ end
243
+ @configuration.plugins_io_allowed_paths.each { |p| roots << File.expand_path(p) }
244
+
245
+ Plugin::TrustPolicy.new(
246
+ trusted_gems: trusted_gems,
247
+ allowed_read_roots: roots,
248
+ network_policy: @configuration.plugins_io_network,
249
+ allowed_url_hosts: @configuration.plugins_io_allowed_url_hosts
250
+ )
251
+ end
252
+
253
+ def trusted_gem_name(entry)
254
+ case entry
255
+ when String then entry
256
+ when Hash then entry["gem"] || entry["id"]
257
+ end
258
+ end
259
+
260
+ def trusted_gem_root(gem_name)
261
+ return nil if gem_name.nil? || gem_name.empty?
262
+
263
+ spec = Gem.loaded_specs[gem_name]
264
+ spec&.full_gem_path # rigor:disable undefined-method
265
+ rescue StandardError
266
+ nil
267
+ end
268
+
269
+ # ADR-9 slice 3 — invokes every loaded plugin's `#prepare`
270
+ # hook once per run, after the loader's `#init` pass and
271
+ # before per-file iteration. Plugins publish facts here
272
+ # for cross-plugin consumption via the shared
273
+ # `services.fact_store`. Failures isolate as
274
+ # `:plugin_loader runtime-error` diagnostics, mirroring the
275
+ # `#diagnostics_for_file` raise envelope in
276
+ # `plugin_runtime_error_diagnostic`.
277
+ #
278
+ # Slice 3 visits plugins in registration order. Slice 5
279
+ # introduces topological ordering by `manifest(consumes:)`
280
+ # so producers always run before consumers; until then,
281
+ # `Configuration#plugins` order MUST be producer-first if
282
+ # cross-plugin dependencies exist.
283
+ def plugin_prepare_diagnostics(plugin_registry)
284
+ return [] if plugin_registry.empty?
285
+
286
+ plugin_registry.plugins.flat_map { |plugin| invoke_plugin_prepare(plugin) }
287
+ end
288
+
289
+ def invoke_plugin_prepare(plugin)
290
+ plugin.prepare(plugin.services)
291
+ []
292
+ rescue StandardError => e
293
+ [plugin_prepare_error_diagnostic(plugin, e)]
294
+ end
295
+
296
+ def plugin_prepare_error_diagnostic(plugin, error)
297
+ plugin_id = safe_plugin_id(plugin)
298
+ Diagnostic.new(
299
+ path: ".rigor.yml",
300
+ line: 1,
301
+ column: 1,
302
+ message: "plugin #{plugin_id.inspect} raised during prepare: " \
303
+ "#{error.class}: #{error.message}",
304
+ severity: :error,
305
+ rule: "runtime-error",
306
+ source_family: :plugin_loader
307
+ )
308
+ end
309
+
310
+ def safe_plugin_id(plugin)
311
+ plugin.manifest.id
312
+ rescue StandardError
313
+ plugin.class.to_s
314
+ end
315
+ end
316
+ end
317
+ end
318
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ class Runner
6
+ # Mutable per-run holder for the four end-of-pass snapshots that
7
+ # the analysis paths compute as a side effect and the rest of the
8
+ # run reads back: the RBS `class_decl_paths` / `signature_paths`
9
+ # tables (consumed by {RunStats}), the synthesized-namespace name
10
+ # list, and the `conforms-to` scan results (consumed by the
11
+ # diagnostic aggregator).
12
+ #
13
+ # The snapshots are written by whichever analysis path ran
14
+ # ({PoolCoordinator} sequential / fork-pool / fallback) and read by
15
+ # the {Runner} and {DiagnosticAggregator}. A shared mutable holder
16
+ # keeps the write/read timing bit-for-bit with the original inline
17
+ # ivars (the same pattern the reporter accumulators use) while
18
+ # letting the writer and readers live in separate objects without a
19
+ # back-reference cycle.
20
+ class RunSnapshots
21
+ attr_accessor :class_decl_paths, :signature_paths,
22
+ :synthesized_namespaces, :conformance_results
23
+
24
+ # Constructor defaults match the {Runner} constructor: the
25
+ # pre-seed values `build_run_stats` / `pre_file_diagnostics` read
26
+ # before the first analysis path runs are frozen empties.
27
+ def initialize
28
+ @class_decl_paths = {}.freeze
29
+ @signature_paths = [].freeze
30
+ @synthesized_namespaces = [].freeze
31
+ @conformance_results = [].freeze
32
+ end
33
+
34
+ # Per-`#run` reset. Mirrors the original `#run` body, which reset
35
+ # these to NON-frozen empties (distinct from the frozen
36
+ # constructor defaults) at the top of each run.
37
+ def reset_for_run
38
+ @class_decl_paths = {}.freeze
39
+ @signature_paths = []
40
+ @synthesized_namespaces = []
41
+ @conformance_results = []
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end