rigortype 0.3.6 → 0.3.8
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 +1 -1
- data/data/builtins/ruby_core/date.yml +6 -3
- data/data/builtins/ruby_core/enumerable.yml +1 -0
- data/data/builtins/ruby_core/exception.yml +2 -1
- data/data/builtins/ruby_core/file.yml +28 -14
- data/data/builtins/ruby_core/hash.yml +5 -2
- data/data/builtins/ruby_core/io.yml +33 -16
- data/data/builtins/ruby_core/random.yml +2 -1
- data/data/builtins/ruby_core/re.yml +6 -3
- data/data/builtins/ruby_core/struct.yml +2 -0
- data/data/builtins/ruby_core/time.yml +2 -1
- data/data/gem_overlay/activesupport/core_ext.rbs +409 -11
- data/docs/handbook/11-sig-gen.md +24 -14
- data/docs/manual/02-cli-reference.md +23 -7
- data/docs/manual/04-diagnostics.md +1 -1
- data/docs/manual/05-inspecting-types.md +20 -8
- data/docs/manual/07-plugins.md +4 -0
- data/docs/manual/plugins/rigor-actionpack.md +61 -0
- data/docs/manual/plugins/rigor-activerecord.md +74 -1
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
- data/docs/manual/plugins/rigor-sidekiq.md +22 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
- data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
- data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
- data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
- data/lib/rigor/analysis/check_rules.rb +383 -48
- data/lib/rigor/analysis/crash_signature.rb +188 -0
- data/lib/rigor/analysis/dependency_recorder.rb +18 -3
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/incremental.rb +28 -0
- data/lib/rigor/analysis/incremental_session.rb +63 -16
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
- data/lib/rigor/analysis/reachability/graph.rb +14 -5
- data/lib/rigor/analysis/reachability/scan.rb +7 -3
- data/lib/rigor/analysis/result.rb +32 -0
- data/lib/rigor/analysis/run_cache_key.rb +41 -1
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +233 -16
- data/lib/rigor/analysis/runner/pool_coordinator.rb +266 -47
- data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
- data/lib/rigor/analysis/runner/run_snapshots.rb +12 -2
- data/lib/rigor/analysis/runner.rb +212 -16
- data/lib/rigor/analysis/worker_session.rb +37 -3
- data/lib/rigor/bleeding_edge.rb +7 -6
- data/lib/rigor/builtins/hkt_builtins.rb +45 -6
- data/lib/rigor/builtins/imported_refinements.rb +11 -9
- data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
- data/lib/rigor/builtins/static_return_refinements.rb +20 -1
- data/lib/rigor/cache/annotation_location.rb +72 -0
- data/lib/rigor/cache/descriptor.rb +42 -2
- data/lib/rigor/cache/incremental_snapshot.rb +40 -3
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +70 -8
- data/lib/rigor/cache/store.rb +17 -1
- data/lib/rigor/cli/coverage_command.rb +23 -18
- data/lib/rigor/cli/coverage_mutation.rb +13 -17
- data/lib/rigor/cli/coverage_scan.rb +47 -6
- data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
- data/lib/rigor/cli/fused_protection_report.rb +23 -2
- data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
- data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
- data/lib/rigor/cli/mutation_protection_report.rb +31 -2
- data/lib/rigor/cli/sig_gen_command.rb +47 -0
- data/lib/rigor/cli/type_of_command.rb +188 -41
- data/lib/rigor/cli/type_of_renderer.rb +72 -3
- data/lib/rigor/cli/type_scan_command.rb +10 -1
- data/lib/rigor/cli.rb +1 -1
- data/lib/rigor/configuration.rb +7 -4
- data/lib/rigor/effects/plugin_facts.rb +1 -1
- data/lib/rigor/effects/scanner.rb +2 -3
- data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
- data/lib/rigor/environment/failure_slot.rb +28 -0
- data/lib/rigor/environment/lockfile_resolver.rb +15 -5
- data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
- data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
- data/lib/rigor/environment/rbs_loader.rb +743 -93
- data/lib/rigor/environment.rb +125 -25
- data/lib/rigor/inference/acceptance.rb +172 -20
- data/lib/rigor/inference/captured_locals.rb +62 -0
- data/lib/rigor/inference/content_join.rb +347 -0
- data/lib/rigor/inference/def_handle.rb +15 -3
- data/lib/rigor/inference/def_node_resolver.rb +62 -3
- data/lib/rigor/inference/expression_typer.rb +1020 -141
- data/lib/rigor/inference/fork_map.rb +6 -1
- data/lib/rigor/inference/hkt_reducer.rb +12 -1
- data/lib/rigor/inference/hkt_registry.rb +61 -3
- data/lib/rigor/inference/hkt_sugar_translator.rb +87 -0
- data/lib/rigor/inference/index_write_widening.rb +48 -0
- data/lib/rigor/inference/indexed_narrowing.rb +14 -0
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
- data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
- data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
- data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
- data/lib/rigor/inference/method_dispatcher.rb +155 -14
- data/lib/rigor/inference/method_parameter_binder.rb +3 -3
- data/lib/rigor/inference/mutation_widening.rb +244 -174
- data/lib/rigor/inference/narrowing.rb +331 -27
- data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
- data/lib/rigor/inference/pre_eval_constants.rb +15 -0
- data/lib/rigor/inference/precision_scanner.rb +5 -2
- data/lib/rigor/inference/project_patched_scanner.rb +2 -1
- data/lib/rigor/inference/rbs_type_translator.rb +89 -39
- data/lib/rigor/inference/scope_indexer.rb +1493 -140
- data/lib/rigor/inference/statement_evaluator.rb +276 -91
- data/lib/rigor/inference/struct_fold_safety.rb +186 -27
- data/lib/rigor/inference/version_guard.rb +229 -0
- data/lib/rigor/language_server/selection_range_provider.rb +1 -1
- data/lib/rigor/plugin/base.rb +3 -2
- data/lib/rigor/plugin/inflector.rb +14 -5
- data/lib/rigor/plugin/io_boundary.rb +107 -4
- data/lib/rigor/plugin/loader.rb +14 -0
- data/lib/rigor/plugin/registry.rb +25 -2
- data/lib/rigor/protection/analysis_guard.rb +60 -0
- data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
- data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
- data/lib/rigor/protection/discovery_seed.rb +1 -1
- data/lib/rigor/protection/measurement_integrity.rb +36 -0
- data/lib/rigor/protection/mutation_scanner.rb +61 -6
- data/lib/rigor/protection/mutator.rb +121 -18
- data/lib/rigor/rbs_extended/envelope_scanner.rb +8 -7
- data/lib/rigor/rbs_extended/hkt_directives.rb +16 -1
- data/lib/rigor/rbs_extended/reporter.rb +93 -13
- data/lib/rigor/rbs_extended.rb +6 -1
- data/lib/rigor/reflection.rb +182 -30
- data/lib/rigor/scope/discovery_index.rb +39 -1
- data/lib/rigor/scope.rb +385 -4
- data/lib/rigor/sig_gen/classification.rb +12 -1
- data/lib/rigor/sig_gen/generator.rb +320 -85
- data/lib/rigor/sig_gen/renderer.rb +14 -12
- data/lib/rigor/sig_gen/writer.rb +3 -0
- data/lib/rigor/source/constant_path.rb +79 -2
- data/lib/rigor/triage/catalogue.rb +1 -1
- data/lib/rigor/type/combinator.rb +10 -0
- data/lib/rigor/type/maybe.rb +47 -0
- data/lib/rigor/type/refined.rb +1 -2
- data/lib/rigor/type/result.rb +53 -0
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
- data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
- data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
- data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
- data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
- data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
- data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
- data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
- data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
- data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
- data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
- data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
- data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
- data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
- data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
- data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
- data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
- data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
- data/sig/rigor/analysis/baseline.rbs +1 -1
- data/sig/rigor/environment.rbs +9 -0
- data/sig/rigor/inference.rbs +4 -1
- data/sig/rigor/plugin/io_boundary.rbs +2 -0
- data/sig/rigor/reflection.rbs +3 -1
- data/sig/rigor/scope.rbs +25 -1
- data/sig/rigor/type.rbs +95 -1
- data/sig/rigor.rbs +16 -0
- metadata +71 -3
|
@@ -247,26 +247,42 @@ module Rigor
|
|
|
247
247
|
return nil if class_index.nil?
|
|
248
248
|
|
|
249
249
|
class_node = ancestors[class_index]
|
|
250
|
+
# A ROOTED enclosing module (`module ::Admin`) resets the qualifier chain, exactly as it does in
|
|
251
|
+
# {ControllerDiscoverer} — the two sides must render the same key or the lookup silently misses
|
|
252
|
+
# (#621).
|
|
250
253
|
enclosing = ancestors[0...class_index]
|
|
251
254
|
.grep(Prism::ModuleNode)
|
|
252
|
-
.
|
|
255
|
+
.each_with_object([]) do |module_node, chain|
|
|
256
|
+
chain.clear if rooted_declaration?(module_node)
|
|
257
|
+
chain.concat(namespace_segments_for(module_node))
|
|
258
|
+
end
|
|
253
259
|
qualified_name_with_enclosing(class_node.constant_path, enclosing)
|
|
254
260
|
end
|
|
255
261
|
|
|
262
|
+
# True when the declaration's constant path is explicitly rooted (`class ::Foo`, `class ::Foo::Bar`)
|
|
263
|
+
# — the leftmost segment is a `ConstantPathNode` with no parent. Mirrors
|
|
264
|
+
# {ControllerDiscoverer#rooted_declaration?}.
|
|
265
|
+
def rooted_declaration?(declaration_node)
|
|
266
|
+
node = declaration_node.constant_path
|
|
267
|
+
node = node.parent while node.is_a?(Prism::ConstantPathNode) && node.parent
|
|
268
|
+
node.is_a?(Prism::ConstantPathNode)
|
|
269
|
+
end
|
|
270
|
+
|
|
256
271
|
def namespace_segments_for(declaration_node)
|
|
257
272
|
path = qualified_name_for(declaration_node.constant_path)
|
|
258
273
|
path ? path.split("::") : []
|
|
259
274
|
end
|
|
260
275
|
|
|
261
|
-
# Resolves a class-name AST node against an enclosing namespace chain. A `ConstantPathNode`
|
|
262
|
-
#
|
|
263
|
-
# (
|
|
276
|
+
# Resolves a class-name AST node against an enclosing namespace chain. A `ConstantPathNode` is
|
|
277
|
+
# already absolute and ignores the chain — either because it is qualified (`class Admin::Foo`) or
|
|
278
|
+
# because it is ROOTED (`class ::Foo`), which names the top-level constant whatever the nesting
|
|
279
|
+
# (#621). A `ConstantReadNode` (bare `class Foo` inside `module Admin`) is qualified against it.
|
|
264
280
|
def qualified_name_with_enclosing(node, enclosing)
|
|
265
281
|
return nil unless node.is_a?(Prism::Node)
|
|
266
282
|
|
|
267
283
|
local = qualified_name_for(node)
|
|
268
284
|
return nil if local.nil?
|
|
269
|
-
return local if node.is_a?(Prism::ConstantPathNode)
|
|
285
|
+
return local if node.is_a?(Prism::ConstantPathNode)
|
|
270
286
|
return local if enclosing.empty?
|
|
271
287
|
|
|
272
288
|
"#{enclosing.join('::')}::#{local}"
|
|
@@ -56,7 +56,8 @@ module Rigor
|
|
|
56
56
|
def ruby_files_under(roots)
|
|
57
57
|
roots.flat_map do |root|
|
|
58
58
|
absolute = File.expand_path(root)
|
|
59
|
-
|
|
59
|
+
# ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
|
|
60
|
+
next [] unless @io_boundary.directory?(absolute)
|
|
60
61
|
|
|
61
62
|
Dir.glob(File.join(absolute, "**", "*.rb"))
|
|
62
63
|
end
|
|
@@ -69,12 +70,34 @@ module Rigor
|
|
|
69
70
|
|
|
70
71
|
walk_declarations(parse_result.value, namespace: []) do |declaration_node, enclosing|
|
|
71
72
|
entry = build_entry(declaration_node, enclosing)
|
|
72
|
-
|
|
73
|
+
next unless entry.class_name
|
|
74
|
+
|
|
75
|
+
existing = entries[entry.class_name]
|
|
76
|
+
entries[entry.class_name] = existing ? merge_entry(existing, entry) : entry
|
|
73
77
|
end
|
|
74
78
|
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
75
79
|
nil
|
|
76
80
|
end
|
|
77
81
|
|
|
82
|
+
# Unions two declarations of the SAME constant. A controller or concern is routinely declared more
|
|
83
|
+
# than once — the real class in `app/controllers/accounts_controller.rb` and a second file reopening
|
|
84
|
+
# it (`class ::AccountsController`, `class AccountsController`) to add an action or a filter target.
|
|
85
|
+
# Ruby's own semantics are ADDITIVE, so nothing either declaration spelled is lost. Assigning the
|
|
86
|
+
# later entry instead dropped the earlier one's `def`s, and `before_action :a_method_the_first_file
|
|
87
|
+
# _defines` then surfaced a false `unknown-filter-method` on correct code (#621).
|
|
88
|
+
#
|
|
89
|
+
# `parent_class_name` takes the first non-nil — a reopen normally omits `< Parent`, and two
|
|
90
|
+
# declarations that both spell one name the same class. `enclosing_namespace` stays the earlier
|
|
91
|
+
# declaration's: it is the lexical scope the merged include / parent names resolve against, and an
|
|
92
|
+
# unresolvable name degrades to {ControllerIndex#unresolved_include?}, which silences the rule.
|
|
93
|
+
def merge_entry(base, addition)
|
|
94
|
+
base.with(
|
|
95
|
+
defined_methods: (base.defined_methods | addition.defined_methods).freeze,
|
|
96
|
+
parent_class_name: base.parent_class_name || addition.parent_class_name,
|
|
97
|
+
included_module_names: (base.included_module_names | addition.included_module_names).freeze
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
78
101
|
# Walks every `ClassNode` / `ModuleNode` in the AST, yielding `[declaration_node,
|
|
79
102
|
# enclosing_namespace_array]` for each. `enclosing` is the chain of module / class qualifiers
|
|
80
103
|
# OUTSIDE the yielded declaration (e.g. for `module Admin; class AccountsController; end; end`
|
|
@@ -86,7 +109,10 @@ module Rigor
|
|
|
86
109
|
|
|
87
110
|
if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
|
|
88
111
|
yield node, namespace
|
|
89
|
-
|
|
112
|
+
# A ROOTED declaration (`class ::Foo`) names the top-level constant whatever its nesting, so it
|
|
113
|
+
# RESETS the qualifier chain its body sees rather than extending the enclosing one (#621).
|
|
114
|
+
inner_namespace = rooted_declaration?(node) ? [] : namespace
|
|
115
|
+
inner_namespace += namespace_segments_for(node)
|
|
90
116
|
return if node.body.nil?
|
|
91
117
|
|
|
92
118
|
walk_declarations(node.body, namespace: inner_namespace, &)
|
|
@@ -119,21 +145,34 @@ module Rigor
|
|
|
119
145
|
)
|
|
120
146
|
end
|
|
121
147
|
|
|
122
|
-
# Resolves the declared name against the enclosing namespace chain. A `ConstantPathNode`
|
|
123
|
-
#
|
|
124
|
-
# `
|
|
125
|
-
# the
|
|
148
|
+
# Resolves the declared name against the enclosing namespace chain. A `ConstantPathNode` is already
|
|
149
|
+
# absolute and ignores the enclosing chain — either because it is qualified (`Admin::Foo` in
|
|
150
|
+
# `class Admin::Foo`) or because it is ROOTED (`::Foo`), which names the top-level constant
|
|
151
|
+
# whatever the nesting (#621). A `ConstantReadNode` (bare `Foo` in `module Admin; class Foo`) is
|
|
152
|
+
# qualified against the chain so the name becomes `Admin::Foo`.
|
|
153
|
+
#
|
|
154
|
+
# {#qualified_name_for} already renders a rooted path WITHOUT its leading `::`, so the entry key is
|
|
155
|
+
# the plain spelling every consumer anchors on.
|
|
126
156
|
def qualified_name_with_enclosing(node, enclosing)
|
|
127
157
|
return nil unless node.is_a?(Prism::Node)
|
|
128
158
|
|
|
129
159
|
local = qualified_name_for(node)
|
|
130
160
|
return nil if local.nil?
|
|
131
|
-
return local if node.is_a?(Prism::ConstantPathNode)
|
|
161
|
+
return local if node.is_a?(Prism::ConstantPathNode)
|
|
132
162
|
return local if enclosing.empty?
|
|
133
163
|
|
|
134
164
|
"#{enclosing.join('::')}::#{local}"
|
|
135
165
|
end
|
|
136
166
|
|
|
167
|
+
# True when the declaration's constant path is explicitly rooted (`class ::Foo`,
|
|
168
|
+
# `class ::Foo::Bar`) — the leftmost segment is a `ConstantPathNode` with no parent. The analyzer
|
|
169
|
+
# carries the same predicate, because both sides must agree on the key a call site looks up.
|
|
170
|
+
def rooted_declaration?(declaration_node)
|
|
171
|
+
node = declaration_node.constant_path
|
|
172
|
+
node = node.parent while node.is_a?(Prism::ConstantPathNode) && node.parent
|
|
173
|
+
node.is_a?(Prism::ConstantPathNode)
|
|
174
|
+
end
|
|
175
|
+
|
|
137
176
|
def parent_name_for(declaration_node)
|
|
138
177
|
return nil unless declaration_node.is_a?(Prism::ClassNode) && declaration_node.superclass
|
|
139
178
|
|
|
@@ -38,9 +38,14 @@ module Rigor
|
|
|
38
38
|
freeze
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# Entries are keyed by the de-rooted constant path (`"AccountsController"`,
|
|
42
|
+
# `"Admin::AccountsController"` — never `"::AccountsController"`; see {ControllerDiscoverer}),
|
|
43
|
+
# while a QUERY may legitimately arrive rooted. The root marker is dropped at every lookup entry
|
|
44
|
+
# point, once, so no caller needs a `find(name) || find("::#{name}")` retry (#621).
|
|
45
|
+
#
|
|
41
46
|
# @return [Entry, nil]
|
|
42
47
|
def find(class_name)
|
|
43
|
-
@entries[class_name]
|
|
48
|
+
@entries[strip_leading_namespace(class_name)]
|
|
44
49
|
end
|
|
45
50
|
|
|
46
51
|
# Resolves the **effective** method set for a controller, including methods contributed by
|
|
@@ -56,7 +61,7 @@ module Rigor
|
|
|
56
61
|
seen_classes = {}
|
|
57
62
|
seen_modules = {}
|
|
58
63
|
methods = []
|
|
59
|
-
current = class_name
|
|
64
|
+
current = strip_leading_namespace(class_name)
|
|
60
65
|
while current && !seen_classes[current]
|
|
61
66
|
seen_classes[current] = true
|
|
62
67
|
entry = @entries[current]
|
|
@@ -87,6 +92,7 @@ module Rigor
|
|
|
87
92
|
# may legitimately contribute the filter (either directly, or via its own ancestor chain which
|
|
88
93
|
# the static analyzer cannot follow), and there's no way to verify.
|
|
89
94
|
def unresolved_include?(class_name)
|
|
95
|
+
class_name = strip_leading_namespace(class_name)
|
|
90
96
|
entry = @entries[class_name]
|
|
91
97
|
return false if entry.nil?
|
|
92
98
|
|
|
@@ -132,7 +138,7 @@ module Rigor
|
|
|
132
138
|
end
|
|
133
139
|
|
|
134
140
|
def known?(class_name)
|
|
135
|
-
@entries.key?(class_name)
|
|
141
|
+
@entries.key?(strip_leading_namespace(class_name))
|
|
136
142
|
end
|
|
137
143
|
|
|
138
144
|
def class_names
|
|
@@ -141,6 +147,10 @@ module Rigor
|
|
|
141
147
|
|
|
142
148
|
private
|
|
143
149
|
|
|
150
|
+
# `::AccountsController` → `AccountsController`. The query-side half of the key contract — see
|
|
151
|
+
# {#find}.
|
|
152
|
+
def strip_leading_namespace(name) = name&.delete_prefix("::")
|
|
153
|
+
|
|
144
154
|
def collect_methods(name, seen, into)
|
|
145
155
|
return if name.nil?
|
|
146
156
|
|
|
@@ -61,8 +61,15 @@ module Rigor
|
|
|
61
61
|
# DomainBlocksController` resolves as `Admin::DomainBlocksController` (matching the
|
|
62
62
|
# `ControllerDiscoverer`), so render paths and filter-chain validation on nested controllers are
|
|
63
63
|
# correct.
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
# Bumped 2026-09-02 (#534 "same lane") — the request predicates and the FlashHash chain join the
|
|
65
|
+
# typed surface. `0.9.0`'s successor is `1.0.0`, not `0.10.0`: AGENTS.md's single-digit rule binds
|
|
66
|
+
# recursively at every position, and it applies here because a manifest version is a cache key
|
|
67
|
+
# rather than a gem release (so the no-autonomous-bump rule does not).
|
|
68
|
+
# Bumped 2026-09-02 (#621) — a reopened controller's `def`s are UNIONed rather than clobbered by
|
|
69
|
+
# the later file in the glob, so a cached 1.0.0 index can be missing the filter targets the merge
|
|
70
|
+
# restores.
|
|
71
|
+
version: "1.1.0",
|
|
72
|
+
description: "Validates Action Pack route-helper calls and filter chains inside controllers, and types the request-context readers (`params` / `session` / `request` / `flash`) and their chains.",
|
|
66
73
|
config_schema: {
|
|
67
74
|
"controller_search_paths" => { kind: :array, default: ["app/controllers"] },
|
|
68
75
|
"view_search_paths" => { kind: :array, default: ["app/views"] }
|
|
@@ -203,7 +210,93 @@ module Rigor
|
|
|
203
210
|
# method on a Parameters value stays engine-lenient (no `undefined-method`), and this types the
|
|
204
211
|
# container only, never a caller's argument (ADR-5). The GitLab strong-params survey (108 leaked
|
|
205
212
|
# `.permit` sites) is the demand.
|
|
206
|
-
|
|
213
|
+
# Issue #534 — `expect` (Rails 8's require+permit) and `slice` joined 2026-09-01, from the corpus
|
|
214
|
+
# sweep's pair counts (63 and 38 sites on mastodon). Both share `require`'s safety shape: they
|
|
215
|
+
# never return nil (`expect` raises like `require`; `slice` always returns a Parameters), so the
|
|
216
|
+
# non-nil lenient nominal cannot fold a flow rule wrong.
|
|
217
|
+
#
|
|
218
|
+
# ## The admission rule for this table
|
|
219
|
+
#
|
|
220
|
+
# A method belongs here **iff its Rails implementation returns an `ActionController::Parameters`
|
|
221
|
+
# on every path** — either a fresh `new_instance_with_inherited_permitted_status(...)` or `self`.
|
|
222
|
+
# That is a stronger bar than "usually returns Parameters", and it is the bar that keeps the
|
|
223
|
+
# entry FP-safe, because the RBS-less nominal buys leniency on the *method surface* only:
|
|
224
|
+
#
|
|
225
|
+
# - Method-surface rules DO decline on it. `call.undefined-method` bails at
|
|
226
|
+
# `Rigor::Reflection.rbs_class_known?` (lib/rigor/analysis/check_rules.rb:720) because Rigor
|
|
227
|
+
# ships no RBS for Parameters, and the argument / arity rules gate on the same predicate
|
|
228
|
+
# (:1151, :2126). So `params.slice(:a).whatever_rails_adds` never fires, and a scalar-shaped
|
|
229
|
+
# use of a Parameters-typed value (`.to_i`, `.strip`, string interpolation) resolves lenient
|
|
230
|
+
# -to-Dynamic rather than to a diagnostic.
|
|
231
|
+
# - **Flow rules do NOT.** Truthiness folding, ternary arm selection and `.nil?` folding key on
|
|
232
|
+
# the type being *nil-free*, not on whether Rigor knows its methods. A non-nil nominal standing
|
|
233
|
+
# in for a value that is nil at runtime is a wrong precise type, and the folds it licenses are
|
|
234
|
+
# real diagnostics on correct code. This is why the table admits only never-nil methods, and
|
|
235
|
+
# why `#[]` is excluded below.
|
|
236
|
+
#
|
|
237
|
+
# Return contracts verified against rails v8.1.0.beta1
|
|
238
|
+
# (`actionpack/lib/action_controller/metal/strong_parameters.rb`). Deliberately excluded even
|
|
239
|
+
# though they are Parameters-shaped: `compact!` (`self if @parameters.compact!` — returns nil
|
|
240
|
+
# when nothing changed, :1038); `select` / `reject` / `filter` / `transform_keys` /
|
|
241
|
+
# `transform_values` (`to_enum(...)` without a block, so the answer depends on the call's block
|
|
242
|
+
# argument, :1004/:1018/:934/:917); `select!` / `keep_if`, whose doc comment ("returns nil if no
|
|
243
|
+
# changes were made") contradicts its body (`self`, :1010) — a contradiction that could resolve
|
|
244
|
+
# either way on a future Rails, and the demand does not justify betting on it; `dig` / `fetch` /
|
|
245
|
+
# `to_h` / `to_unsafe_h`, which return a caller value, nil, or a HashWithIndifferentAccess.
|
|
246
|
+
# `reject!` / `delete_if` / `compact_blank!` DO return `self` on every path (:1024/:1028/:1050)
|
|
247
|
+
# and are admissible under the same argument — deferred only to keep this change at the
|
|
248
|
+
# reviewed set; admit them with the next batch.
|
|
249
|
+
#
|
|
250
|
+
# ## `Parameters#[]`: rejected 2026-09-01, adopted 2026-09-05 (issue #534 item 1, #574)
|
|
251
|
+
#
|
|
252
|
+
# `#[]` is the single largest named-receiver pair on both survey apps (redmine 581, mastodon
|
|
253
|
+
# ~475), so it is the entry with by far the most to gain — and both typings of it were measured
|
|
254
|
+
# on a fixture controller of ordinary Rails idioms before being rejected:
|
|
255
|
+
#
|
|
256
|
+
# - **`#[] -> Parameters` (non-nil).** Types the whole chain (`params[:a][:b][:c]` resolves), but
|
|
257
|
+
# the non-nil nominal is a *wrong* precise type at a missing key, and the flow rules act on it.
|
|
258
|
+
# Every `params[:k]` condition becomes always-truthy, so a ternary over one folds to its true
|
|
259
|
+
# arm: `mode = params[:full] ? :full : :short` types `mode` as `:full`, and the live guard
|
|
260
|
+
# `return if mode == :short` draws `flow.always-truthy-condition` — a diagnostic on a branch
|
|
261
|
+
# the program really takes. The same fold proves `if url.nil?` unreachable and types its body
|
|
262
|
+
# `bot`. This reproduces the withdrawal measured on five working redmine/mastodon controllers.
|
|
263
|
+
# (A shape like `x = params[:flag] ? nil : "a"; x.upcase` also changes answer, but it is a true
|
|
264
|
+
# positive both before and after — `String | nil` genuinely can be nil — so it is evidence of
|
|
265
|
+
# the fold, not of an FP. The two shapes above are the ones that are silent today and wrong
|
|
266
|
+
# under this typing, which is why they are what the spec pins.)
|
|
267
|
+
# - **`#[] -> Parameters | nil`.** Fixes every fold above (no ternary collapse, no `bot` branch)
|
|
268
|
+
# and still carries the chain — `params[:user][:name]` types `Parameters?` and
|
|
269
|
+
# `params[:user].permit(:name)` types `Parameters`, because the dispatcher strips the nil arm
|
|
270
|
+
# for the receiver gate. Idiomatic *chained* reads stay silent too: `call.possible-nil-receiver`
|
|
271
|
+
# restricts itself to `Prism::LocalVariableReadNode` receivers (`CheckRules#nil_receiver_diagnostic`'s local-read restriction), so
|
|
272
|
+
# `params[:q].strip` cannot fire. What it does fire on is the assigned form — `q = params[:q];
|
|
273
|
+
# q.strip` — at **error** severity, once per unguarded use. Guarded forms (`return if q.nil?`,
|
|
274
|
+
# `if q`, `q&.strip`, `|| ""`) all narrow correctly and stay silent, so this is not a broken
|
|
275
|
+
# rule; it is the rule working, on a shape working Rails controllers use constantly. Adopting
|
|
276
|
+
# it would put an error on correct code in exchange for a coverage metric.
|
|
277
|
+
#
|
|
278
|
+
# The first trade is still refused: `-> Parameters` buys protection-coverage with false positives,
|
|
279
|
+
# which inverts the project's ordering of those two goods (AGENTS.md: "false positives outrank
|
|
280
|
+
# worst-case static reading").
|
|
281
|
+
#
|
|
282
|
+
# The SECOND is no longer a trade. The `possible-nil-receiver` firing above was never this typing
|
|
283
|
+
# being wrong — it was the rules-level vacuity #574 describes, and the paragraph that stood here
|
|
284
|
+
# named the precondition exactly: "a receiver-position nullable the flow rules read as unknown
|
|
285
|
+
# rather than as nil". #574's witness gate is that change. `ActionController::Parameters` ships no
|
|
286
|
+
# RBS and has no project source, so the gate now reads it as UNKNOWN rather than as a full witness,
|
|
287
|
+
# the nil-arm has nothing confirming it, and the rule declines. `q = params[:q]; q.strip` is silent
|
|
288
|
+
# on the shape that rejected this typing, measured through the real CLI rather than argued.
|
|
289
|
+
#
|
|
290
|
+
# The control specs stay, and they still bite: they pin the HAZARD (no truthiness fold, no `bot`
|
|
291
|
+
# branch, no possible-nil firing), not the Dynamic answer, so they go red if either typing's
|
|
292
|
+
# hazard ever returns — and they now carry a positive assertion that `params[:k]` types
|
|
293
|
+
# `Parameters?`, so a silent regression to `Dynamic` or to the non-nil nominal fails them too.
|
|
294
|
+
STRONG_PARAMS_CHAIN_METHODS = %i[
|
|
295
|
+
require permit permit! expect slice
|
|
296
|
+
except without extract! slice!
|
|
297
|
+
merge merge! reverse_merge reverse_merge! with_defaults with_defaults!
|
|
298
|
+
compact compact_blank deep_dup
|
|
299
|
+
].freeze
|
|
207
300
|
|
|
208
301
|
dynamic_return receivers: [REQUEST_CONTEXT_READER_TYPES[:params]], methods: STRONG_PARAMS_CHAIN_METHODS do |call_node, _scope|
|
|
209
302
|
next nil unless call_node.is_a?(Prism::CallNode)
|
|
@@ -211,6 +304,150 @@ module Rigor
|
|
|
211
304
|
Rigor::Type::Combinator.nominal_of(REQUEST_CONTEXT_READER_TYPES[:params])
|
|
212
305
|
end
|
|
213
306
|
|
|
307
|
+
# Issue #534 item 1, reopened by #574. `#[]` is the single largest named-receiver pair on both survey
|
|
308
|
+
# apps (redmine 581, mastodon ~475). The honest contract is `Parameters | nil` — a missing key really
|
|
309
|
+
# returns nil — and the block comment above records why that typing was REJECTED in 2026-09-01: it put
|
|
310
|
+
# `call.possible-nil-receiver` at ERROR severity on `q = params[:q]; q.strip`, a shape working Rails
|
|
311
|
+
# controllers use constantly.
|
|
312
|
+
#
|
|
313
|
+
# That firing was a rules-level vacuity, not this typing being wrong, and #574 fixed it: the
|
|
314
|
+
# possible-nil witness gate counted a named-but-RBS-less arm as answerable, so a plugin-minted
|
|
315
|
+
# nominal with no RBS and no project source stood in as a full witness for a question nothing could
|
|
316
|
+
# answer. With the gate requiring a KNOWABLE arm, the nil-arm has no confirmed witness and the rule
|
|
317
|
+
# declines — which is the FP-first ordering, and what the comment above named as the precondition
|
|
318
|
+
# ("a receiver-position nullable the flow rules read as unknown rather than as nil").
|
|
319
|
+
#
|
|
320
|
+
# The non-nil `-> Parameters` typing stays rejected on its own merits, unchanged: it makes every
|
|
321
|
+
# `params[:k]` condition always-truthy and folds a ternary over one to its true arm.
|
|
322
|
+
dynamic_return receivers: [REQUEST_CONTEXT_READER_TYPES[:params]], methods: %i[[]] do |call_node, _scope|
|
|
323
|
+
next nil unless call_node.is_a?(Prism::CallNode)
|
|
324
|
+
|
|
325
|
+
Rigor::Type::Combinator.union(
|
|
326
|
+
Rigor::Type::Combinator.nominal_of(REQUEST_CONTEXT_READER_TYPES[:params]),
|
|
327
|
+
Rigor::Type::Combinator.constant_of(nil)
|
|
328
|
+
)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Phase 5c (2026-09-02, issue #534 "same lane") — the rest of the request-context surface the
|
|
332
|
+
# corpus sweep measured after the #578 / #585 batch: `request.post?` 28 and `request.xhr?` 27 on
|
|
333
|
+
# the two apps, `flash.now` inside the redmine 147 / mastodon 29 flash cluster. Each is typed from
|
|
334
|
+
# its Rails/Rack source contract, verified at v8.0.2 / rack v3.1.8.
|
|
335
|
+
#
|
|
336
|
+
# ## The predicates return a real `bool`, and a real `bool` does not fold
|
|
337
|
+
#
|
|
338
|
+
# Every method in this table is a zero-argument predicate whose body is a genuine boolean
|
|
339
|
+
# expression — `request_method == POST` and its nine siblings in `Rack::Request::Helpers`,
|
|
340
|
+
# `/XMLHttpRequest/i.match?(...)` for `xhr?` / `xml_http_request?`, `scheme == "https" || scheme ==
|
|
341
|
+
# "wss"` for `ssl?`, two `LOCALHOST.match?` conjuncts for `local?`, and
|
|
342
|
+
# `FORM_DATA_MEDIA_TYPES.include?(media_type)` for `form_data?`. None can return a non-boolean, so
|
|
343
|
+
# `true | false` is the exact type rather than an approximation.
|
|
344
|
+
#
|
|
345
|
+
# `bool` is a UNION of the two constants, and that is what makes it FP-safe where a nominal would
|
|
346
|
+
# not be: `flow.always-truthy-condition` fires when the flow proves the condition folds to ONE
|
|
347
|
+
# constant, and a union of both never does. Measured on the shapes controllers actually write —
|
|
348
|
+
# `return unless request.post?`, `if request.xhr? … else … end`, `mode = request.get? ? :a : :b`
|
|
349
|
+
# followed by a live `mode == :a` guard, `request.post? && x`, `!request.get?` — the answer is zero
|
|
350
|
+
# diagnostics, unchanged from the `Dynamic[top]` baseline. This is the opposite of the
|
|
351
|
+
# `Parameters#[]` case (#578): there the candidate type was a non-nil *nominal* standing in for a
|
|
352
|
+
# value that is nil at runtime, and the fold it licensed was wrong.
|
|
353
|
+
#
|
|
354
|
+
# The distinction the predicates turn on is *inertness*, not truthfulness, and it is narrower than
|
|
355
|
+
# it looks: a two-constant union is inert because neither arm can be eliminated, while ANY nil-free
|
|
356
|
+
# type — nominal or union of nominals — is not. `request.format` was withdrawn from this batch on
|
|
357
|
+
# exactly that point (see below).
|
|
358
|
+
REQUEST_PREDICATE_METHODS = %i[
|
|
359
|
+
get? post? put? patch? delete? head? options? trace? link? unlink?
|
|
360
|
+
xhr? xml_http_request? ssl? local? form_data?
|
|
361
|
+
].freeze
|
|
362
|
+
|
|
363
|
+
dynamic_return receivers: [REQUEST_CONTEXT_READER_TYPES[:request]], methods: REQUEST_PREDICATE_METHODS do |call_node, _scope|
|
|
364
|
+
next nil unless call_node.is_a?(Prism::CallNode)
|
|
365
|
+
next nil unless call_node.arguments.nil?
|
|
366
|
+
next nil unless call_node.block.nil?
|
|
367
|
+
|
|
368
|
+
Rigor::Type::Combinator.union(
|
|
369
|
+
Rigor::Type::Combinator.constant_of(true),
|
|
370
|
+
Rigor::Type::Combinator.constant_of(false)
|
|
371
|
+
)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# The FlashHash methods whose Rails body returns something this plugin can name on every path.
|
|
375
|
+
#
|
|
376
|
+
# - `now` is `@now ||= FlashNow.new(self)` — a memoised, never-nil `ActionDispatch::Flash::FlashNow`.
|
|
377
|
+
# It is the carrier behind `flash.now[:alert] = "…"`, the second-most-written flash idiom.
|
|
378
|
+
# - `keep` and `discard` are `k ? self[k] : self`, so they are admissible ONLY in their
|
|
379
|
+
# zero-argument form, where the answer is `self` — a FlashHash. Given a key they return a leaf
|
|
380
|
+
# value or nil, which is the hazard below. The rule reads the arity syntactically, so the two
|
|
381
|
+
# spellings get different answers from the same table row.
|
|
382
|
+
#
|
|
383
|
+
# Both nominals are RBS-less, hence lenient, for the reason `REQUEST_CONTEXT_READER_TYPES`
|
|
384
|
+
# documents.
|
|
385
|
+
FLASH_CHAIN_TYPES = {
|
|
386
|
+
now: "ActionDispatch::Flash::FlashNow",
|
|
387
|
+
keep: REQUEST_CONTEXT_READER_TYPES[:flash],
|
|
388
|
+
discard: REQUEST_CONTEXT_READER_TYPES[:flash]
|
|
389
|
+
}.freeze
|
|
390
|
+
|
|
391
|
+
# `keep` / `discard` answer `self` only when called with no key.
|
|
392
|
+
FLASH_SELF_RETURNING_METHODS = %i[keep discard].freeze
|
|
393
|
+
|
|
394
|
+
dynamic_return receivers: [REQUEST_CONTEXT_READER_TYPES[:flash]], methods: FLASH_CHAIN_TYPES.keys do |call_node, _scope|
|
|
395
|
+
next nil unless call_node.is_a?(Prism::CallNode)
|
|
396
|
+
next nil unless call_node.arguments.nil?
|
|
397
|
+
next nil unless call_node.block.nil?
|
|
398
|
+
|
|
399
|
+
class_name = FLASH_CHAIN_TYPES[call_node.name]
|
|
400
|
+
next nil if class_name.nil?
|
|
401
|
+
|
|
402
|
+
Rigor::Type::Combinator.nominal_of(class_name)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# ## What deliberately does NOT land here (issue #534, adjudicated 2026-09-02)
|
|
406
|
+
#
|
|
407
|
+
# **`request.format` was withdrawn from this batch and is filed as its own issue.** It was written
|
|
408
|
+
# as `Mime::Type | Mime::NullType` — a union read straight off `formats.first ||
|
|
409
|
+
# Mime::NullType.instance` — and review found the reasoning behind it wrong on the point that
|
|
410
|
+
# matters. A two-class union of nominals is NOT flow-inert the way `true | false` is: neither Mime
|
|
411
|
+
# arm is falsey, so the whole union is nil-free, and `mode = request.format ? :f : :n; return "a" if
|
|
412
|
+
# mode == :n` draws `flow.always-truthy-condition`. That fold is truthful at runtime — `format`
|
|
413
|
+
# really never returns nil — but truthfulness is not the bar this plugin has been holding, and the
|
|
414
|
+
# fold was never adjudicated against the shapes controllers write.
|
|
415
|
+
#
|
|
416
|
+
# `Mime::NullType` is what makes it more than a paperwork problem. It is a nil-MASQUERADING object:
|
|
417
|
+
# `mime_type.rb` (v8.0.2) gives it an explicit `def nil?; true; end`, so a nil-free union is the
|
|
418
|
+
# wrong model of it, and mastodon's `account_controller_concern.rb` really does branch on
|
|
419
|
+
# `request.format.nil?`. Measured today, `.nil?` on the union does not fold — but that is a
|
|
420
|
+
# consequence of the arms being RBS-less, not of anything this table asserts, and nothing pinned it.
|
|
421
|
+
# Typing `format` needs a nil-aware answer and its own adjudication, not a row here.
|
|
422
|
+
#
|
|
423
|
+
# **`FlashHash#[]=` and `Session#[]=` need no rule, and adding one could only do harm.** Two
|
|
424
|
+
# independent measurements say so. First, Ruby's assignment-expression semantics make the value of
|
|
425
|
+
# `flash[:notice] = msg` the RHS whatever `[]=` returns, and Rigor already models that: on a
|
|
426
|
+
# controller fixture with no plugin rule at all, `flash[:notice] = "hi"` types `"hi"` and
|
|
427
|
+
# `session[:user_id] = 1` types `1`. Second, `coverage --protection` scores a fixture of five such
|
|
428
|
+
# writes at **6/6 (100%)** — the lens measures *receiver* concreteness, and `flash` / `session` are
|
|
429
|
+
# already concrete nominals, so there is no coverage to win either. The corpus's "`FlashHash#[]=`
|
|
430
|
+
# 127 / 29" is a *named-receiver pair* count (a dispatch whose method does not resolve), which no
|
|
431
|
+
# return-type contribution can move. A rule here could only agree with the answer already given, or
|
|
432
|
+
# contradict it.
|
|
433
|
+
#
|
|
434
|
+
# **`FlashHash#[]` and `Session#[]` stay untyped, and both candidate arms were run.** Both are leaf
|
|
435
|
+
# reads — `@flashes[k.to_s]` and the session delegate's `[]` — returning whatever was stored, or
|
|
436
|
+
# nil for a key that is not set, so this is `Parameters#[]` (#578) again and it measures the same:
|
|
437
|
+
#
|
|
438
|
+
# - **non-nil `String`**: `mode = flash[:notice] ? :flash : :plain` folds to `:flash`, and the live
|
|
439
|
+
# `mode == :plain` guard draws `flow.always-truthy-condition` — a diagnostic on a branch the
|
|
440
|
+
# program takes.
|
|
441
|
+
# - **`String | nil`**: no folds, but `note = flash[:notice]; note.upcase` fires
|
|
442
|
+
# `call.possible-nil-receiver` at ERROR severity — the assigned-then-used shape controllers write
|
|
443
|
+
# constantly. (`uid.to_i` is silent because `NilClass` has `to_i`, which makes the noise
|
|
444
|
+
# unpredictable rather than absent.)
|
|
445
|
+
#
|
|
446
|
+
# One false positive each, on ordinary controller code, in exchange for a coverage number. Session
|
|
447
|
+
# is if anything worse than Parameters: `session[:user_id]` is *the* nil-checked read in Rails, so
|
|
448
|
+
# the fold lands on the guard that matters most. Reopening either needs the same rules-level change
|
|
449
|
+
# #574 tracks, not a plugin table row. The specs pin both arms as executable controls.
|
|
450
|
+
|
|
214
451
|
private
|
|
215
452
|
|
|
216
453
|
# True when the current `self` is a controller — the enclosing class is one the discoverer
|
|
@@ -225,7 +462,8 @@ module Rigor
|
|
|
225
462
|
return false if name.nil?
|
|
226
463
|
|
|
227
464
|
index = producer_value(:controller_index)
|
|
228
|
-
|
|
465
|
+
# `ControllerIndex#find` de-roots the query itself (#621) — no retry arm here.
|
|
466
|
+
return true if index&.find(name)
|
|
229
467
|
|
|
230
468
|
name.end_with?("Controller")
|
|
231
469
|
end
|
|
@@ -35,7 +35,9 @@ module Rigor
|
|
|
35
35
|
class_name = constant_receiver_name(call_node.receiver)
|
|
36
36
|
return [] if class_name.nil?
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
# `JobIndex#find` de-roots the query itself (#621) — a `::WelcomeJob.perform_later` receiver and a
|
|
39
|
+
# plain `WelcomeJob.perform_later` reach the same entry with no retry arm here.
|
|
40
|
+
entry = job_index.find(class_name)
|
|
39
41
|
return [] if entry.nil?
|
|
40
42
|
|
|
41
43
|
violations = [info_violation(call_node, entry)]
|
|
@@ -135,7 +135,7 @@ module Rigor
|
|
|
135
135
|
# per-environment split has no single transport, and guessing one would be the wrong kind of
|
|
136
136
|
# precision.
|
|
137
137
|
def detect_adapter(io_boundary, root)
|
|
138
|
-
found = config_files(root).filter_map do |path|
|
|
138
|
+
found = config_files(io_boundary, root).filter_map do |path|
|
|
139
139
|
io_boundary.read_file(path).force_encoding("UTF-8")[SETTING, 1]
|
|
140
140
|
rescue StandardError
|
|
141
141
|
nil
|
|
@@ -143,10 +143,14 @@ module Rigor
|
|
|
143
143
|
found.length == 1 ? found.first : nil
|
|
144
144
|
end
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
# ADR-45 WD1b (#613) — `config/application.rb` is probed at a fixed path, so its ABSENCE is what
|
|
147
|
+
# shapes "no adapter" on a project that has none yet; the probe goes through the boundary to record
|
|
148
|
+
# that. The globbed `config/environments/*.rb` entries exist by enumeration and only pick up the
|
|
149
|
+
# (immediately superseded) presence row.
|
|
150
|
+
def config_files(io_boundary, root)
|
|
147
151
|
application = File.join(root, CONFIG_PATHS.first)
|
|
148
152
|
environments = Dir.glob(File.join(root, CONFIG_PATHS.last, "*.rb"))
|
|
149
|
-
([application] + environments).select { |path|
|
|
153
|
+
([application] + environments).select { |path| io_boundary.file?(path) }
|
|
150
154
|
rescue StandardError
|
|
151
155
|
[]
|
|
152
156
|
end
|
|
@@ -26,26 +26,61 @@ module Rigor
|
|
|
26
26
|
def initialize(io_boundary:, search_paths:, base_classes:)
|
|
27
27
|
@io_boundary = io_boundary
|
|
28
28
|
@search_paths = search_paths
|
|
29
|
-
|
|
29
|
+
# De-rooted for the same reason superclass names are ({#visit_class}): the match is by exact
|
|
30
|
+
# spelling, so a configured `job_base_classes: ["::ApplicationJob"]` would otherwise never match
|
|
31
|
+
# anything a declaration can render.
|
|
32
|
+
@base_classes = base_classes.to_set { |name| strip_root(name.to_s) }
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
# @return [JobIndex]
|
|
33
36
|
def discover
|
|
34
|
-
|
|
37
|
+
candidates = []
|
|
35
38
|
ruby_files_under(@search_paths).each do |path|
|
|
36
39
|
contents = read_safely(path)
|
|
37
40
|
next if contents.nil?
|
|
38
41
|
|
|
39
42
|
tree = Prism.parse(contents).value
|
|
40
43
|
walk_for_jobs(tree, []) do |class_name, perform_def|
|
|
41
|
-
|
|
44
|
+
candidates << [class_name, perform_def]
|
|
42
45
|
end
|
|
43
46
|
end
|
|
44
|
-
JobIndex.new(
|
|
47
|
+
JobIndex.new(merge_redeclarations(candidates))
|
|
45
48
|
end
|
|
46
49
|
|
|
47
50
|
private
|
|
48
51
|
|
|
52
|
+
# Collapses the candidates that declare the SAME constant into one entry, so {JobIndex} — which
|
|
53
|
+
# keys its rows by `class_name` — receives at most one per job.
|
|
54
|
+
#
|
|
55
|
+
# A job is routinely declared more than once: `app/jobs/welcome_job.rb` holds the real class and a
|
|
56
|
+
# second file reopens it (`class ::WelcomeJob`, `class WelcomeJob`) to add a method. Taking the last
|
|
57
|
+
# candidate dropped the real `#perform` envelope whenever the reopen sorted later in the glob — the
|
|
58
|
+
# reopen carries no `#perform`, so the job silently became any-arity and every `perform_later` went
|
|
59
|
+
# unchecked. The rows are merged instead:
|
|
60
|
+
#
|
|
61
|
+
# - The declarations that actually spell `def perform` decide the envelope; a reopen that does not
|
|
62
|
+
# contributes nothing rather than erasing it.
|
|
63
|
+
# - When two declarations BOTH spell it with different shapes, the envelope WIDENS (min of the mins,
|
|
64
|
+
# max of the maxes) and the required keywords intersect. The glob order is not the load order, so
|
|
65
|
+
# pinning either shape would surface `wrong-arity` on a call the definition Ruby actually loads
|
|
66
|
+
# accepts — ADR-5: a missed narrow case costs precision, a wrong one costs correct code.
|
|
67
|
+
def merge_redeclarations(candidates)
|
|
68
|
+
candidates.group_by(&:first).map do |class_name, rows|
|
|
69
|
+
declared = rows.filter_map { |(_, perform_def)| perform_def }
|
|
70
|
+
entries = declared.empty? ? [build_entry(class_name, nil)] : declared.map { |d| build_entry(class_name, d) }
|
|
71
|
+
entries.reduce { |base, addition| widen(base, addition) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The arity envelope that accepts every call either declaration accepts. See {#merge_redeclarations}.
|
|
76
|
+
def widen(base, addition)
|
|
77
|
+
base.with(
|
|
78
|
+
min_arity: [base.min_arity, addition.min_arity].min,
|
|
79
|
+
max_arity: [base.max_arity, addition.max_arity].max,
|
|
80
|
+
keyword_required: base.keyword_required & addition.keyword_required
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
49
84
|
def read_safely(path)
|
|
50
85
|
@io_boundary.read_file(path)
|
|
51
86
|
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
@@ -55,7 +90,8 @@ module Rigor
|
|
|
55
90
|
def ruby_files_under(roots)
|
|
56
91
|
roots.flat_map do |root|
|
|
57
92
|
absolute = File.expand_path(root)
|
|
58
|
-
|
|
93
|
+
# ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
|
|
94
|
+
next [] unless @io_boundary.directory?(absolute)
|
|
59
95
|
|
|
60
96
|
Dir.glob(File.join(absolute, "**", "*.rb"))
|
|
61
97
|
end
|
|
@@ -76,26 +112,41 @@ module Rigor
|
|
|
76
112
|
class_local_name = constant_path_name(node.constant_path)
|
|
77
113
|
return if class_local_name.nil?
|
|
78
114
|
|
|
79
|
-
full_name = (lexical_path
|
|
80
|
-
superclass = constant_path_name(node.superclass) if node.superclass
|
|
115
|
+
full_name = declared_constant_name(class_local_name, lexical_path)
|
|
116
|
+
superclass = strip_root(constant_path_name(node.superclass)) if node.superclass
|
|
81
117
|
if superclass && @base_classes.include?(superclass)
|
|
82
118
|
perform_def = lookup_perform_def(node.body)
|
|
83
119
|
yield full_name, perform_def
|
|
84
120
|
end
|
|
85
121
|
|
|
86
|
-
|
|
87
|
-
walk_for_jobs(node.body, inner_path, &) if node.body
|
|
122
|
+
walk_for_jobs(node.body, [full_name], &) if node.body
|
|
88
123
|
end
|
|
89
124
|
|
|
90
125
|
def visit_module(node, lexical_path, &)
|
|
91
126
|
module_local_name = constant_path_name(node.constant_path)
|
|
92
127
|
return if module_local_name.nil?
|
|
93
128
|
|
|
94
|
-
inner_path =
|
|
129
|
+
inner_path = [declared_constant_name(module_local_name, lexical_path)]
|
|
95
130
|
walk_for_jobs(node.body, inner_path, &) if node.body
|
|
96
131
|
end
|
|
97
132
|
|
|
98
|
-
#
|
|
133
|
+
# The full constant name a `class` / `module` declaration defines, given the rendered local name
|
|
134
|
+
# and the enclosing lexical path. A ROOTED local name (`class ::WelcomeJob`) names the top-level
|
|
135
|
+
# constant whatever the nesting, so the lexical path is dropped and the `::` with it; otherwise the
|
|
136
|
+
# name is appended to the path (`class WelcomeJob` inside `module Admin` → `Admin::WelcomeJob`).
|
|
137
|
+
def declared_constant_name(local_name, lexical_path)
|
|
138
|
+
return strip_root(local_name) if local_name.start_with?("::")
|
|
139
|
+
|
|
140
|
+
(lexical_path + [local_name]).join("::")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# `::WelcomeJob` → `WelcomeJob`; a name without the root marker is returned unchanged (nil stays nil).
|
|
144
|
+
def strip_root(name)
|
|
145
|
+
name&.delete_prefix("::")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Renders `Foo::Bar` / `::Foo::Bar` as a String, KEEPING the leading `::` of a rooted path —
|
|
149
|
+
# {#declared_constant_name} reads it as "reset the lexical path" before the marker is dropped.
|
|
99
150
|
def constant_path_name(node)
|
|
100
151
|
return nil if node.nil?
|
|
101
152
|
|