rigortype 0.3.5 → 0.3.7
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 +5 -5
- 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 +442 -11
- data/docs/handbook/02-everyday-types.md +1 -1
- data/docs/handbook/04-tuples-and-shapes.md +1 -1
- data/docs/handbook/08-understanding-errors.md +1 -1
- data/docs/handbook/09-plugins.md +2 -2
- data/docs/handbook/10-sorbet.md +1 -1
- data/docs/handbook/README.md +2 -2
- data/docs/handbook/appendix-go.md +1 -1
- data/docs/handbook/appendix-java-csharp.md +2 -2
- data/docs/handbook/appendix-mypy.md +1 -1
- data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
- data/docs/handbook/appendix-rust.md +1 -1
- data/docs/handbook/appendix-type-theory.md +2 -2
- data/docs/handbook/appendix-typescript.md +4 -4
- data/docs/manual/02-cli-reference.md +29 -16
- data/docs/manual/03-configuration.md +1 -1
- data/docs/manual/04-diagnostics.md +12 -0
- data/docs/manual/05-inspecting-types.md +20 -8
- data/docs/manual/07-plugins.md +4 -0
- data/docs/manual/08-skills.md +1 -1
- data/docs/manual/11-ci.md +1 -1
- data/docs/manual/12-caching.md +1 -1
- data/docs/manual/15-type-protection-coverage.md +1 -1
- data/docs/manual/18-removing-dead-code.md +13 -4
- data/docs/manual/19-effect-labels.md +19 -12
- data/docs/manual/README.md +2 -2
- data/docs/manual/plugins/rigor-actionmailer.md +4 -4
- data/docs/manual/plugins/rigor-actionpack.md +61 -0
- data/docs/manual/plugins/rigor-activejob.md +3 -3
- data/docs/manual/plugins/rigor-activerecord.md +78 -5
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
- data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
- data/docs/manual/plugins/rigor-rspec.md +6 -2
- 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 +116 -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/diagnostic.rb +17 -6
- data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
- data/lib/rigor/analysis/incremental.rb +28 -0
- data/lib/rigor/analysis/incremental_session.rb +44 -9
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
- data/lib/rigor/analysis/reachability/graph.rb +44 -16
- data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
- data/lib/rigor/analysis/reachability/scan.rb +27 -7
- data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
- data/lib/rigor/analysis/result.rb +32 -0
- data/lib/rigor/analysis/run_cache_key.rb +53 -1
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
- data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
- data/lib/rigor/analysis/runner.rb +298 -38
- data/lib/rigor/analysis/worker_session.rb +10 -1
- data/lib/rigor/bleeding_edge.rb +7 -6
- data/lib/rigor/builtins/hkt_builtins.rb +45 -6
- data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
- data/lib/rigor/builtins/static_return_refinements.rb +20 -1
- data/lib/rigor/cache/descriptor.rb +42 -2
- data/lib/rigor/cache/file_digest.rb +20 -2
- data/lib/rigor/cache/incremental_snapshot.rb +40 -3
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
- data/lib/rigor/cache/store.rb +10 -1
- data/lib/rigor/cli/check_command.rb +67 -48
- data/lib/rigor/cli/coverage_command.rb +28 -24
- data/lib/rigor/cli/coverage_mutation.rb +13 -17
- data/lib/rigor/cli/coverage_scan.rb +47 -6
- data/lib/rigor/cli/doc_links.rb +100 -0
- data/lib/rigor/cli/docs_command.rb +32 -2
- data/lib/rigor/cli/effects_command.rb +27 -2
- data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
- data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
- data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
- 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 +18 -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/unused_command.rb +65 -16
- data/lib/rigor/cli.rb +22 -9
- data/lib/rigor/configuration.rb +7 -4
- data/lib/rigor/effects/definition_lines.rb +100 -0
- data/lib/rigor/effects/envelope_check.rb +18 -1
- data/lib/rigor/effects/liskov_check.rb +17 -8
- data/lib/rigor/effects/plugin_facts.rb +1 -1
- data/lib/rigor/effects/scanner.rb +2 -3
- data/lib/rigor/effects/signature_sources.rb +13 -2
- data/lib/rigor/effects/snapshot.rb +53 -21
- data/lib/rigor/effects/snapshot_diff.rb +26 -3
- data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
- 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 +525 -46
- data/lib/rigor/environment.rb +38 -15
- 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 +46 -0
- data/lib/rigor/inference/hkt_sugar_translator.rb +93 -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/synthetic_method_scanner.rb +7 -0
- 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/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 +9 -0
- 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 +254 -5
- data/lib/rigor/sig_gen/renderer.rb +1 -6
- 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 +3 -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
- data/skills/rigor-ci-setup/SKILL.md +2 -2
- data/skills/rigor-editor-setup/SKILL.md +2 -2
- data/skills/rigor-mcp-setup/SKILL.md +2 -2
- data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
- data/skills/rigor-plugin-review/SKILL.md +3 -3
- metadata +73 -3
|
@@ -36,7 +36,11 @@ module Rigor
|
|
|
36
36
|
def initialize(io_boundary:, search_paths:, base_classes:)
|
|
37
37
|
@io_boundary = io_boundary
|
|
38
38
|
@search_paths = search_paths
|
|
39
|
-
|
|
39
|
+
# De-rooted for the same reason superclass names are ({#visit_class}): the match is by exact
|
|
40
|
+
# spelling, so a channel written `< ::ApplicationCable::Channel` — or a configured
|
|
41
|
+
# `channel_base_classes: ["::ApplicationCable::Channel"]` — would otherwise never match, and the
|
|
42
|
+
# channel would go undiscovered and its `broadcast_to` call sites report `unknown-channel`.
|
|
43
|
+
@base_classes = base_classes.to_set { |name| strip_root(name.to_s) }
|
|
40
44
|
end
|
|
41
45
|
|
|
42
46
|
# @return [ChannelIndex]
|
|
@@ -51,11 +55,39 @@ module Rigor
|
|
|
51
55
|
entries << build_entry(class_name, path, body)
|
|
52
56
|
end
|
|
53
57
|
end
|
|
54
|
-
ChannelIndex.new(entries)
|
|
58
|
+
ChannelIndex.new(merge_redeclarations(entries))
|
|
55
59
|
end
|
|
56
60
|
|
|
57
61
|
private
|
|
58
62
|
|
|
63
|
+
# Collapses the entries that declare the SAME constant into one, so {ChannelIndex} — which keys its
|
|
64
|
+
# by-name view by `class_name` — receives at most one row per channel.
|
|
65
|
+
#
|
|
66
|
+
# A channel is routinely declared more than once: the real class in `app/channels/chat_channel.rb`
|
|
67
|
+
# and a second file reopening it (`class ::ChatChannel`, `class ChatChannel`) to add an action.
|
|
68
|
+
# Ruby's own semantics are ADDITIVE, so the rows are UNIONed: action methods and literal stream
|
|
69
|
+
# names union, and `dynamic_streams` ORs — any dynamic registration anywhere in the class means no
|
|
70
|
+
# literal name can be proven missing, which is the false-positive-safe reading. Taking the last row
|
|
71
|
+
# instead dropped the earlier declaration's actions and streams whenever it sorted later in the glob.
|
|
72
|
+
# `file_path` stays the earlier declaration's; nothing reads it by name.
|
|
73
|
+
def merge_redeclarations(entries)
|
|
74
|
+
return entries if entries.length < 2
|
|
75
|
+
|
|
76
|
+
entries.each_with_object({}) do |entry, acc|
|
|
77
|
+
name = entry.class_name
|
|
78
|
+
acc[name] = acc.key?(name) ? merged_entry(acc[name], entry) : entry
|
|
79
|
+
end.values
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# `base` is the earlier declaration, `addition` the later one. See {#merge_redeclarations}.
|
|
83
|
+
def merged_entry(base, addition)
|
|
84
|
+
base.with(
|
|
85
|
+
action_methods: (base.action_methods | addition.action_methods).freeze,
|
|
86
|
+
stream_names: (base.stream_names | addition.stream_names).freeze,
|
|
87
|
+
dynamic_streams: base.dynamic_streams || addition.dynamic_streams
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
59
91
|
def read_safely(path)
|
|
60
92
|
@io_boundary.read_file(path)
|
|
61
93
|
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
@@ -65,7 +97,8 @@ module Rigor
|
|
|
65
97
|
def ruby_files_under(roots)
|
|
66
98
|
roots.flat_map do |root|
|
|
67
99
|
absolute = File.expand_path(root)
|
|
68
|
-
|
|
100
|
+
# ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
|
|
101
|
+
next [] unless @io_boundary.directory?(absolute)
|
|
69
102
|
|
|
70
103
|
Dir.glob(File.join(absolute, "**", "*.rb"))
|
|
71
104
|
end
|
|
@@ -86,22 +119,37 @@ module Rigor
|
|
|
86
119
|
class_local_name = constant_path_name(node.constant_path)
|
|
87
120
|
return if class_local_name.nil?
|
|
88
121
|
|
|
89
|
-
full_name = (lexical_path
|
|
90
|
-
superclass = constant_path_name(node.superclass) if node.superclass
|
|
122
|
+
full_name = declared_constant_name(class_local_name, lexical_path)
|
|
123
|
+
superclass = strip_root(constant_path_name(node.superclass)) if node.superclass
|
|
91
124
|
yield full_name, node.body if superclass && @base_classes.include?(superclass)
|
|
92
125
|
|
|
93
|
-
|
|
94
|
-
walk_for_channels(node.body, inner_path, &) if node.body
|
|
126
|
+
walk_for_channels(node.body, [full_name], &) if node.body
|
|
95
127
|
end
|
|
96
128
|
|
|
97
129
|
def visit_module(node, lexical_path, &)
|
|
98
130
|
module_local_name = constant_path_name(node.constant_path)
|
|
99
131
|
return if module_local_name.nil?
|
|
100
132
|
|
|
101
|
-
inner_path =
|
|
133
|
+
inner_path = [declared_constant_name(module_local_name, lexical_path)]
|
|
102
134
|
walk_for_channels(node.body, inner_path, &) if node.body
|
|
103
135
|
end
|
|
104
136
|
|
|
137
|
+
# The full constant name a `class` / `module` declaration defines, given the rendered local name
|
|
138
|
+
# and the enclosing lexical path. A ROOTED local name (`class ::ChatChannel`) names the top-level
|
|
139
|
+
# constant whatever the nesting, so the lexical path is dropped and the `::` with it; otherwise the
|
|
140
|
+
# name is appended to the path (`class ChatChannel` inside `module Admin` → `Admin::ChatChannel`).
|
|
141
|
+
def declared_constant_name(local_name, lexical_path)
|
|
142
|
+
return strip_root(local_name) if local_name.start_with?("::")
|
|
143
|
+
|
|
144
|
+
(lexical_path + [local_name]).join("::")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# `::ChatChannel` → `ChatChannel`; a name without the root marker is returned unchanged (nil stays
|
|
148
|
+
# nil).
|
|
149
|
+
def strip_root(name)
|
|
150
|
+
name&.delete_prefix("::")
|
|
151
|
+
end
|
|
152
|
+
|
|
105
153
|
def build_entry(class_name, path, body)
|
|
106
154
|
actions = []
|
|
107
155
|
(body&.compact_child_nodes || []).each do |node|
|
|
@@ -154,6 +202,8 @@ module Rigor
|
|
|
154
202
|
true
|
|
155
203
|
end
|
|
156
204
|
|
|
205
|
+
# Renders a constant-path node as a String, KEEPING the leading `::` of a rooted path —
|
|
206
|
+
# {#declared_constant_name} reads it as "reset the lexical path" before the marker is dropped.
|
|
157
207
|
def constant_path_name(node)
|
|
158
208
|
return nil if node.nil?
|
|
159
209
|
|
|
@@ -26,19 +26,27 @@ module Rigor
|
|
|
26
26
|
|
|
27
27
|
attr_reader :entries
|
|
28
28
|
|
|
29
|
+
# `entries` is expected UNIQUE by `class_name`: the by-name Hash is keyed by it, so a second row for
|
|
30
|
+
# a channel would replace the first outright. {ChannelDiscoverer#merge_redeclarations} is the single
|
|
31
|
+
# home of that guarantee — a reopened class arrives as one merged row, never as two.
|
|
29
32
|
def initialize(entries)
|
|
30
33
|
@entries = entries.freeze
|
|
31
34
|
@by_name = entries.to_h { |entry| [entry.class_name, entry] }.freeze
|
|
32
35
|
freeze
|
|
33
36
|
end
|
|
34
37
|
|
|
38
|
+
# Entries are keyed by the de-rooted constant path (`"ChatChannel"`, `"Admin::ChatChannel"` — never
|
|
39
|
+
# `"::ChatChannel"`; see {ChannelDiscoverer}), while a QUERY may legitimately arrive rooted:
|
|
40
|
+
# `::ChatChannel.broadcast_to(...)` renders its receiver as `"::ChatChannel"`. The root marker is
|
|
41
|
+
# dropped here, once, so no caller needs a `find(name) || find("::#{name}")` retry (#621).
|
|
42
|
+
#
|
|
35
43
|
# @return [Entry, nil]
|
|
36
44
|
def find(class_name)
|
|
37
|
-
@by_name[class_name.to_s]
|
|
45
|
+
@by_name[strip_leading_namespace(class_name.to_s)]
|
|
38
46
|
end
|
|
39
47
|
|
|
40
48
|
def known?(class_name)
|
|
41
|
-
@by_name.key?(class_name.to_s)
|
|
49
|
+
@by_name.key?(strip_leading_namespace(class_name.to_s))
|
|
42
50
|
end
|
|
43
51
|
|
|
44
52
|
def empty?
|
|
@@ -64,6 +72,11 @@ module Rigor
|
|
|
64
72
|
def any_dynamic_streams?
|
|
65
73
|
@entries.any?(&:dynamic_streams)
|
|
66
74
|
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# `::ChatChannel` → `ChatChannel`. The query-side half of the key contract — see {#find}.
|
|
79
|
+
def strip_leading_namespace(name) = name.delete_prefix("::")
|
|
67
80
|
end
|
|
68
81
|
end
|
|
69
82
|
end
|
|
@@ -65,7 +65,10 @@ module Rigor
|
|
|
65
65
|
class Actioncable < Rigor::Plugin::Base
|
|
66
66
|
manifest(
|
|
67
67
|
id: "actioncable",
|
|
68
|
-
|
|
68
|
+
# Bumped 2026-09-02 (#621) — channel keys and base-class names are de-rooted at the producer and a
|
|
69
|
+
# reopened class's actions and streams are UNIONed rather than clobbered, so a cached 0.1.0 index
|
|
70
|
+
# can be missing a whole channel (`< ::ApplicationCable::Channel`) or half its stream names.
|
|
71
|
+
version: "0.2.0",
|
|
69
72
|
description: "Validates ActionCable broadcast call shape against discovered channels.",
|
|
70
73
|
config_schema: {
|
|
71
74
|
"channel_search_paths" => { kind: :array, default: ["app/channels"] },
|
|
@@ -53,7 +53,9 @@ module Rigor
|
|
|
53
53
|
return [] if class_name.nil?
|
|
54
54
|
return [] if RESERVED_CLASS_METHODS.include?(call_node.name)
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
# `MailerIndex#find` de-roots the query itself (#621) — a `::UserMailer.welcome` receiver and a
|
|
57
|
+
# plain `UserMailer.welcome` reach the same entry with no retry arm here.
|
|
58
|
+
class_entry = mailer_index.find(class_name)
|
|
57
59
|
return [] if class_entry.nil?
|
|
58
60
|
|
|
59
61
|
action_entry = class_entry.find_action(call_node.name)
|
|
@@ -46,7 +46,10 @@ module Rigor
|
|
|
46
46
|
def initialize(io_boundary:, search_paths:, base_classes:, views_root: DEFAULT_VIEWS_ROOT)
|
|
47
47
|
@io_boundary = io_boundary
|
|
48
48
|
@search_paths = search_paths
|
|
49
|
-
|
|
49
|
+
# De-rooted for the same reason superclass names are ({#visit_class}): the match is by exact
|
|
50
|
+
# spelling, so a configured `mailer_base_classes: ["::ApplicationMailer"]` would otherwise never
|
|
51
|
+
# match anything a declaration can render.
|
|
52
|
+
@base_classes = base_classes.to_set { |name| strip_root(name.to_s) }
|
|
50
53
|
@views_root = views_root
|
|
51
54
|
end
|
|
52
55
|
|
|
@@ -86,7 +89,8 @@ module Rigor
|
|
|
86
89
|
def ruby_files_under(roots)
|
|
87
90
|
roots.flat_map do |root|
|
|
88
91
|
absolute = File.expand_path(root)
|
|
89
|
-
|
|
92
|
+
# ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
|
|
93
|
+
next [] unless @io_boundary.directory?(absolute)
|
|
90
94
|
|
|
91
95
|
Dir.glob(File.join(absolute, "**", "*.rb"))
|
|
92
96
|
end
|
|
@@ -107,16 +111,15 @@ module Rigor
|
|
|
107
111
|
class_local_name = constant_path_name(node.constant_path)
|
|
108
112
|
return if class_local_name.nil?
|
|
109
113
|
|
|
110
|
-
full_name = (lexical_path
|
|
111
|
-
superclass = constant_path_name(node.superclass) if node.superclass
|
|
114
|
+
full_name = declared_constant_name(class_local_name, lexical_path)
|
|
115
|
+
superclass = strip_root(constant_path_name(node.superclass)) if node.superclass
|
|
112
116
|
if superclass && @base_classes.include?(superclass)
|
|
113
117
|
def_nodes = collect_action_defs(node.body)
|
|
114
118
|
includes = collect_includes(node.body)
|
|
115
119
|
yield full_name, def_nodes, includes
|
|
116
120
|
end
|
|
117
121
|
|
|
118
|
-
|
|
119
|
-
walk_for_mailers(node.body, inner_path, &) if node.body
|
|
122
|
+
walk_for_mailers(node.body, [full_name], &) if node.body
|
|
120
123
|
end
|
|
121
124
|
|
|
122
125
|
# Collects qualified-constant names passed to `include X` calls inside the class body. Used to
|
|
@@ -130,8 +133,8 @@ module Rigor
|
|
|
130
133
|
next unless node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :include
|
|
131
134
|
|
|
132
135
|
(node.arguments&.arguments || []).each do |arg|
|
|
133
|
-
name = constant_path_name(arg)
|
|
134
|
-
names << name
|
|
136
|
+
name = strip_root(constant_path_name(arg))
|
|
137
|
+
names << name if name
|
|
135
138
|
end
|
|
136
139
|
end
|
|
137
140
|
names
|
|
@@ -148,16 +151,19 @@ module Rigor
|
|
|
148
151
|
local_name = constant_path_name(node.constant_path)
|
|
149
152
|
return if local_name.nil?
|
|
150
153
|
|
|
151
|
-
full_name = (lexical_path
|
|
154
|
+
full_name = declared_constant_name(local_name, lexical_path)
|
|
152
155
|
if node.body
|
|
153
156
|
def_nodes = collect_action_defs(node.body)
|
|
154
157
|
entries = def_nodes.to_h { |def_node| [def_node.name, build_action_entry(def_node)] }
|
|
155
|
-
|
|
156
|
-
|
|
158
|
+
# UNIONed, never assigned: a concern module reopened in a second file adds actions rather
|
|
159
|
+
# than replacing the ones the first declaration spelled (same rule as {MailerIndex}'s
|
|
160
|
+
# per-class merge).
|
|
161
|
+
accumulator[full_name] = (accumulator[full_name] || {}).merge(entries) unless entries.empty?
|
|
162
|
+
collect_module_actions(node.body, [full_name], accumulator)
|
|
157
163
|
end
|
|
158
164
|
when Prism::ClassNode
|
|
159
165
|
local_name = constant_path_name(node.constant_path)
|
|
160
|
-
inner = local_name ?
|
|
166
|
+
inner = local_name ? [declared_constant_name(local_name, lexical_path)] : lexical_path
|
|
161
167
|
collect_module_actions(node.body, inner, accumulator) if node.body
|
|
162
168
|
else
|
|
163
169
|
node.rigor_each_child { |child| collect_module_actions(child, lexical_path, accumulator) }
|
|
@@ -168,10 +174,27 @@ module Rigor
|
|
|
168
174
|
module_local_name = constant_path_name(node.constant_path)
|
|
169
175
|
return if module_local_name.nil?
|
|
170
176
|
|
|
171
|
-
inner_path =
|
|
177
|
+
inner_path = [declared_constant_name(module_local_name, lexical_path)]
|
|
172
178
|
walk_for_mailers(node.body, inner_path, &) if node.body
|
|
173
179
|
end
|
|
174
180
|
|
|
181
|
+
# The full constant name a `class` / `module` declaration defines, given the rendered local name
|
|
182
|
+
# and the enclosing lexical path. A ROOTED local name (`class ::UserMailer`) names the top-level
|
|
183
|
+
# constant whatever the nesting, so the lexical path is dropped and the `::` with it; otherwise the
|
|
184
|
+
# name is appended to the path (`class UserMailer` inside `module Admin` → `Admin::UserMailer`).
|
|
185
|
+
def declared_constant_name(local_name, lexical_path)
|
|
186
|
+
return strip_root(local_name) if local_name.start_with?("::")
|
|
187
|
+
|
|
188
|
+
(lexical_path + [local_name]).join("::")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# `::UserMailer` → `UserMailer`; a name without the root marker is returned unchanged (nil stays nil).
|
|
192
|
+
def strip_root(name)
|
|
193
|
+
name&.delete_prefix("::")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Renders a constant-path node as a String, KEEPING the leading `::` of a rooted path —
|
|
197
|
+
# {#declared_constant_name} reads it as "reset the lexical path" before the marker is dropped.
|
|
175
198
|
def constant_path_name(node)
|
|
176
199
|
return nil if node.nil?
|
|
177
200
|
|
|
@@ -334,7 +357,9 @@ module Rigor
|
|
|
334
357
|
# ADR-39: the real ActiveSupport::Inflector resolves the mailer's view directory
|
|
335
358
|
# (`Foo::BarMailer` → `foo/bar_mailer`), so a divergence from Rails' real underscore can't
|
|
336
359
|
# point the missing-view check at the wrong directory.
|
|
337
|
-
|
|
360
|
+
# `class_name` is already de-rooted ({#declared_constant_name}), so no root marker can reach the
|
|
361
|
+
# inflection.
|
|
362
|
+
underscore_path = Rigor::Plugin::Inflector.underscore(class_name)
|
|
338
363
|
mailer_dir = File.join(views_root_absolute, underscore_path)
|
|
339
364
|
|
|
340
365
|
VIEW_FORMATS.any? do |format|
|
|
@@ -40,19 +40,32 @@ module Rigor
|
|
|
40
40
|
|
|
41
41
|
attr_reader :entries
|
|
42
42
|
|
|
43
|
+
# `entries` holds one row PER DECLARATION — a mailer declared in two files contributes two — because
|
|
44
|
+
# {#find_by_file} anchors the `missing-view` diagnostics on the file each action's `def` lives in.
|
|
45
|
+
# The by-name view a call site consults is the UNION of a class's declarations ({.merge_entry}): a
|
|
46
|
+
# reopen ADDS actions rather than replacing the class, so keeping the last row alone reported
|
|
47
|
+
# `unknown-action` on every action the other declaration spelled (#621).
|
|
43
48
|
def initialize(entries)
|
|
44
49
|
@entries = entries.freeze
|
|
45
|
-
@by_name = entries.
|
|
50
|
+
@by_name = entries.each_with_object({}) do |entry, acc|
|
|
51
|
+
name = entry.class_name
|
|
52
|
+
acc[name] = acc.key?(name) ? self.class.merge_entry(acc[name], entry) : entry
|
|
53
|
+
end.freeze
|
|
46
54
|
freeze
|
|
47
55
|
end
|
|
48
56
|
|
|
57
|
+
# Entries are keyed by the de-rooted constant path (`"UserMailer"`, `"Admin::UserMailer"` — never
|
|
58
|
+
# `"::UserMailer"`; see {MailerDiscoverer}), while a QUERY may legitimately arrive rooted:
|
|
59
|
+
# `::UserMailer.welcome(u)` renders its receiver as `"::UserMailer"`. The root marker is dropped
|
|
60
|
+
# here, once, so no caller needs a `find(name) || find("::#{name}")` retry (#621).
|
|
61
|
+
#
|
|
49
62
|
# @return [ClassEntry, nil]
|
|
50
63
|
def find(class_name)
|
|
51
|
-
@by_name[class_name.to_s]
|
|
64
|
+
@by_name[strip_leading_namespace(class_name.to_s)]
|
|
52
65
|
end
|
|
53
66
|
|
|
54
67
|
def known?(class_name)
|
|
55
|
-
@by_name.key?(class_name.to_s)
|
|
68
|
+
@by_name.key?(strip_leading_namespace(class_name.to_s))
|
|
56
69
|
end
|
|
57
70
|
|
|
58
71
|
# @param file_path [String] absolute path of a mailer file (canonicalised — see plugin entry's
|
|
@@ -73,6 +86,26 @@ module Rigor
|
|
|
73
86
|
def names
|
|
74
87
|
@by_name.keys
|
|
75
88
|
end
|
|
89
|
+
|
|
90
|
+
# Unions two declarations of the SAME mailer class. `base` is the earlier row, `addition` the later
|
|
91
|
+
# one; Ruby's own semantics are additive, so nothing either declaration spelled is lost. Actions are
|
|
92
|
+
# name-keyed (a redefinition in the later file wins, as it does at load time), `missing_views` and
|
|
93
|
+
# `unresolved_includes` union, and `file_path` stays the earlier declaration's — the merged row is
|
|
94
|
+
# only ever reached by name, never by {#find_by_file}, which reads the per-declaration `entries`.
|
|
95
|
+
def self.merge_entry(base, addition)
|
|
96
|
+
base.with(
|
|
97
|
+
actions: base.actions.merge(addition.actions),
|
|
98
|
+
missing_views: (base.missing_views | addition.missing_views).freeze,
|
|
99
|
+
unresolved_includes: (base.unresolved_includes | addition.unresolved_includes).freeze
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# `::UserMailer` → `UserMailer`. The query-side half of the key contract — see {#find}.
|
|
104
|
+
def self.strip_leading_namespace(name) = name.delete_prefix("::")
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def strip_leading_namespace(name) = self.class.strip_leading_namespace(name)
|
|
76
109
|
end
|
|
77
110
|
end
|
|
78
111
|
end
|
|
@@ -47,7 +47,11 @@ module Rigor
|
|
|
47
47
|
# `send` / `__send__` / `method` and friends so dynamic-dispatch idioms
|
|
48
48
|
# (`Mailer.respond_to?(action)` / `Mailer.public_send(action)`) stop firing `unknown-action`
|
|
49
49
|
# against the Ruby reflection method.
|
|
50
|
-
|
|
50
|
+
#
|
|
51
|
+
# Bumped 2026-09-02 (#621) — mailer keys are de-rooted at the producer and a reopened class's
|
|
52
|
+
# actions are UNIONed rather than clobbered, so a cached 0.4.0 index can be missing actions this
|
|
53
|
+
# version knows about.
|
|
54
|
+
version: "0.5.0",
|
|
51
55
|
description: "Validates ActionMailer call shape and view template existence.",
|
|
52
56
|
config_schema: {
|
|
53
57
|
"mailer_search_paths" => { kind: :array, default: ["app/mailers"] },
|
|
@@ -120,7 +124,7 @@ module Rigor
|
|
|
120
124
|
severity: :warning,
|
|
121
125
|
rule: "missing-view",
|
|
122
126
|
message: "`#{class_entry.class_name}##{action_name}` has no view template " \
|
|
123
|
-
"under `#{@views_root}/#{Rigor::Plugin::Inflector.underscore(class_entry.class_name
|
|
127
|
+
"under `#{@views_root}/#{Rigor::Plugin::Inflector.underscore(class_entry.class_name)}/`"
|
|
124
128
|
)
|
|
125
129
|
end
|
|
126
130
|
end
|
|
@@ -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
|
|