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
|
@@ -14,8 +14,18 @@ module Rigor
|
|
|
14
14
|
# * {.qualified_name_or_nil} is STRICT — a dynamic base anywhere in the chain yields `nil`, so a caller
|
|
15
15
|
# that statically names constants can treat the path as opaque rather than guessing.
|
|
16
16
|
#
|
|
17
|
-
# A leading `::` (absolute root, `::Foo`) renders as `"Foo"` under both policies
|
|
18
|
-
#
|
|
17
|
+
# A leading `::` (absolute root, `::Foo`) renders as `"Foo"` under both policies: the discovered-constant
|
|
18
|
+
# tables are keyed by un-rooted names, so a rendered `"::Foo"` would miss every one of them. The root
|
|
19
|
+
# marker is therefore carried OUT OF BAND by {.rooted?}, which a caller performing Ruby's lexical constant
|
|
20
|
+
# lookup MUST consult — `::Foo` names the top-level `Foo` and never a lexically nearer shadow
|
|
21
|
+
# ([#614](https://github.com/rigortype/rigor/issues/614)). A node that is neither a `ConstantReadNode`
|
|
22
|
+
# nor a `ConstantPathNode` yields `nil` under both policies.
|
|
23
|
+
#
|
|
24
|
+
# A `class` / `module` HEADER is a constant path too, and it has to consult the same marker — which is why
|
|
25
|
+
# {.declaration_prefix} and {.pushed_nesting} live here rather than in each of the two dozen walks that
|
|
26
|
+
# used to append the rendered name to their own prefix. `::` re-anchors a header at the top level exactly
|
|
27
|
+
# as it re-anchors a reference; reading only the rendered name filed `class ::Foo` inside `module MyApp`
|
|
28
|
+
# under `MyApp::Foo` ([#708](https://github.com/rigortype/rigor/issues/708)).
|
|
19
29
|
module ConstantPath
|
|
20
30
|
module_function
|
|
21
31
|
|
|
@@ -53,6 +63,73 @@ module Rigor
|
|
|
53
63
|
"#{parent_name}::#{node.name}"
|
|
54
64
|
end
|
|
55
65
|
end
|
|
66
|
+
|
|
67
|
+
# True when the reference is written with a leading `::` — `::Foo`, `::Foo::Bar`. Prism spells the
|
|
68
|
+
# root as a `ConstantPathNode` with a nil `parent`, so the answer lives at the LEFTMOST segment of the
|
|
69
|
+
# chain and the walk has to reach it: `::Foo::Bar` is a path node whose parent is itself a path node.
|
|
70
|
+
# A `ConstantReadNode` is a bare name and is never rooted; a dynamic base (`expr::Bar`) is not a root
|
|
71
|
+
# either, so the recursion stops at any non-constant parent.
|
|
72
|
+
def rooted?(node)
|
|
73
|
+
return false unless node.is_a?(Prism::ConstantPathNode)
|
|
74
|
+
|
|
75
|
+
parent = node.parent
|
|
76
|
+
return true if parent.nil?
|
|
77
|
+
|
|
78
|
+
rooted?(parent)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The qualified prefix a `class` / `module` header contributes to the body it opens, given the prefix of
|
|
82
|
+
# the body the header is WRITTEN in. `class Bar` inside `module Outer` is `Outer::Bar`; the compact
|
|
83
|
+
# `class Outer::Bar` written at the top level is the same name through one keyword instead of two.
|
|
84
|
+
#
|
|
85
|
+
# A ROOTED header RESETS the prefix. `class ::Rooted::Bar` names `Rooted::Bar` wherever it is written —
|
|
86
|
+
# Ruby's `::` re-anchors the header at the top level exactly as it does a reference — so the enclosing
|
|
87
|
+
# prefix is DROPPED rather than kept. Keeping it filed the class under `Outer::Rooted::Bar`, a name no
|
|
88
|
+
# caller writes, and the single-segment `class ::Foo` inside `module MyApp` became `MyApp::Foo`: not
|
|
89
|
+
# merely mis-keyed but undiscoverable, so a call on `Foo` typed opaque and reported nothing at all
|
|
90
|
+
# ([#708](https://github.com/rigortype/rigor/issues/708), [#638](https://github.com/rigortype/rigor/issues/638)).
|
|
91
|
+
#
|
|
92
|
+
# nil when the header names no constant — the same refusal {.qualified_name} makes, propagated so a
|
|
93
|
+
# caller keeps its own prefix rather than qualifying under a name it could not render.
|
|
94
|
+
def declaration_prefix(outer_prefix, constant_path)
|
|
95
|
+
name = qualified_name(constant_path)
|
|
96
|
+
return nil if name.nil?
|
|
97
|
+
|
|
98
|
+
rooted?(constant_path) ? [name] : outer_prefix + [name]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Ruby's `Module.nesting` inside the body a `class` / `module` header opens, given the nesting of the
|
|
102
|
+
# body the header is written in. ONE entry per declaration keyword, qualified against the entry already
|
|
103
|
+
# on top, so a compact `class A::B` contributes the single `"A::B"` where `module A; class B` contributes
|
|
104
|
+
# two — the whole difference between the two spellings, and unrecoverable from the `"A::B"` they share
|
|
105
|
+
# afterwards ([#652](https://github.com/rigortype/rigor/issues/652)). This is the ONLY thing that pushes
|
|
106
|
+
# an entry: a `def`, a block, and a `class << expr` body each inherit the chain unchanged, because none
|
|
107
|
+
# of them pushes a cref in Ruby.
|
|
108
|
+
#
|
|
109
|
+
# A ROOTED header pushes its name UNQUALIFIED and leaves the enclosing entries beneath it, which is the
|
|
110
|
+
# one place this differs from {.declaration_prefix}: Ruby's nesting inside `class ::Rooted::Bar` written
|
|
111
|
+
# in `module Outer` is `[Rooted::Bar, Outer]`, so the class's own name resets while `Outer` stays on the
|
|
112
|
+
# ladder as a live rung ([#708](https://github.com/rigortype/rigor/issues/708)).
|
|
113
|
+
#
|
|
114
|
+
# The header is rendered by the LENIENT {.qualified_name}, which is total over every header Ruby parses —
|
|
115
|
+
# `class` and `module` require a constant path, so the only dynamic base the grammar admits is `self`, and
|
|
116
|
+
# `class self::Thing` inside `module Outer` renders `"Thing"` and records `["Outer::Thing", "Outer"]`,
|
|
117
|
+
# which is what Ruby's nesting is there. So there is no unnameable-header case to refuse, and the `nil`
|
|
118
|
+
# guards below are reachable only for a non-constant node (which a `ClassNode` / `ModuleNode` header
|
|
119
|
+
# never is) and for the nil chain a caller may thread in.
|
|
120
|
+
#
|
|
121
|
+
# Both nesting walks call THIS function — `Inference::StatementEvaluator`'s declaration walk and
|
|
122
|
+
# `Inference::ScopeIndexer`'s def-node walk — so a callee re-walk answers what the body's own walk
|
|
123
|
+
# answers by construction rather than by two mirrored implementations staying in step.
|
|
124
|
+
def pushed_nesting(nesting, constant_path)
|
|
125
|
+
return nil if nesting.nil?
|
|
126
|
+
|
|
127
|
+
name = qualified_name(constant_path)
|
|
128
|
+
return nil if name.nil?
|
|
129
|
+
|
|
130
|
+
outer = rooted?(constant_path) ? nil : nesting.first
|
|
131
|
+
[outer ? "#{outer}::#{name}" : name, *nesting].freeze
|
|
132
|
+
end
|
|
56
133
|
end
|
|
57
134
|
end
|
|
58
135
|
end
|
|
@@ -327,7 +327,7 @@ module Rigor
|
|
|
327
327
|
parser == :toplevel ? d.method_name : parse_undefined_method(d)&.fetch(:method)
|
|
328
328
|
end
|
|
329
329
|
names.tally.sort_by { |method, count| [-count, method] }
|
|
330
|
-
|
|
330
|
+
.first(limit).map { |method, count| "#{method}×#{count}" }.join(" ")
|
|
331
331
|
end
|
|
332
332
|
|
|
333
333
|
def rule_of(diag)
|
|
@@ -369,6 +369,16 @@ module Rigor
|
|
|
369
369
|
StructInstance.new(members, class_name)
|
|
370
370
|
end
|
|
371
371
|
|
|
372
|
+
# ADR-3 amendment (ADR-20 slice 4) — generic Result[T, E] carrier constructor.
|
|
373
|
+
def result_of(ok_type, err_type)
|
|
374
|
+
Result.new(ok_type, err_type)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# ADR-3 amendment (ADR-20 slice 4) — generic Maybe[T] carrier constructor.
|
|
378
|
+
def maybe_of(value_type)
|
|
379
|
+
Maybe.new(value_type)
|
|
380
|
+
end
|
|
381
|
+
|
|
372
382
|
# Normalized union. Flattens nested Unions, deduplicates structurally equal members, drops Bot, and
|
|
373
383
|
# collapses 0/1-member results.
|
|
374
384
|
def union(*types)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../trinary"
|
|
4
|
+
require_relative "../value_semantics"
|
|
5
|
+
require_relative "acceptance_router"
|
|
6
|
+
require_relative "plain_lattice"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Type
|
|
10
|
+
# A generic Maybe carrier representing an optional value that either contains a value of type
|
|
11
|
+
# `value_type` or nothing.
|
|
12
|
+
#
|
|
13
|
+
# Models `Maybe[T]` (e.g. `Dry::Monads::Maybe[T]`).
|
|
14
|
+
#
|
|
15
|
+
# Subtyping is covariant: `Maybe[T1] <: Maybe[T2]` iff `T1 <: T2`.
|
|
16
|
+
class Maybe
|
|
17
|
+
attr_reader :value_type
|
|
18
|
+
|
|
19
|
+
def initialize(value_type)
|
|
20
|
+
raise ArgumentError, "value_type must not be nil" if value_type.nil?
|
|
21
|
+
|
|
22
|
+
@value_type = value_type
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def describe(verbosity = :short)
|
|
27
|
+
"Maybe[#{value_type.describe(verbosity)}]"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def erase_to_rbs
|
|
31
|
+
"::Dry::Monads::Maybe[#{value_type.erase_to_rbs}]"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
include Rigor::Type::PlainLattice
|
|
35
|
+
|
|
36
|
+
include Rigor::Type::AcceptanceRouter
|
|
37
|
+
|
|
38
|
+
include Rigor::ValueSemantics
|
|
39
|
+
|
|
40
|
+
value_fields :value_type
|
|
41
|
+
|
|
42
|
+
def inspect
|
|
43
|
+
"#<Rigor::Type::Maybe #{describe(:short)}>"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/rigor/type/refined.rb
CHANGED
|
@@ -77,14 +77,13 @@ module Rigor
|
|
|
77
77
|
# Recognises a Ruby value against this carrier's predicate. The trinary return is intentional:
|
|
78
78
|
# `true` / `false` when the predicate registry decides, `nil` when the predicate is unknown to the
|
|
79
79
|
# registry, so callers (today {Inference::Acceptance}) can fall through to gradual-mode `:maybe`.
|
|
80
|
-
# rubocop:disable Style/ReturnNilInPredicateMethodDefinition
|
|
80
|
+
# rubocop:disable-next Style/ReturnNilInPredicateMethodDefinition
|
|
81
81
|
def matches?(value)
|
|
82
82
|
recogniser = PREDICATES[predicate_id]
|
|
83
83
|
return nil if recogniser.nil?
|
|
84
84
|
|
|
85
85
|
!!recogniser.call(value)
|
|
86
86
|
end
|
|
87
|
-
# rubocop:enable Style/ReturnNilInPredicateMethodDefinition
|
|
88
87
|
|
|
89
88
|
# `predicate_id => recogniser` table. The recogniser is called with a Ruby value (typically the
|
|
90
89
|
# inner `value` of a `Constant`) and returns truthy when the value satisfies the predicate. The
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../trinary"
|
|
4
|
+
require_relative "../value_semantics"
|
|
5
|
+
require_relative "acceptance_router"
|
|
6
|
+
require_relative "plain_lattice"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Type
|
|
10
|
+
# A generic Result carrier representing a computation that either succeeded with a value of type
|
|
11
|
+
# `ok_type` or failed with an error of type `err_type`.
|
|
12
|
+
#
|
|
13
|
+
# Models `Result[T, E]` (e.g. `Dry::Monads::Result[T, E]`).
|
|
14
|
+
#
|
|
15
|
+
# Subtyping is covariant in both positions: `Result[T1, E1] <: Result[T2, E2]` iff `T1 <: T2` and
|
|
16
|
+
# `E1 <: E2`.
|
|
17
|
+
class Result
|
|
18
|
+
attr_reader :ok_type, :err_type
|
|
19
|
+
|
|
20
|
+
alias success_type ok_type
|
|
21
|
+
alias failure_type err_type
|
|
22
|
+
|
|
23
|
+
def initialize(ok_type, err_type)
|
|
24
|
+
raise ArgumentError, "ok_type must not be nil" if ok_type.nil?
|
|
25
|
+
raise ArgumentError, "err_type must not be nil" if err_type.nil?
|
|
26
|
+
|
|
27
|
+
@ok_type = ok_type
|
|
28
|
+
@err_type = err_type
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def describe(verbosity = :short)
|
|
33
|
+
"Result[#{ok_type.describe(verbosity)}, #{err_type.describe(verbosity)}]"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def erase_to_rbs
|
|
37
|
+
"::Dry::Monads::Result[#{ok_type.erase_to_rbs}, #{err_type.erase_to_rbs}]"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
include Rigor::Type::PlainLattice
|
|
41
|
+
|
|
42
|
+
include Rigor::Type::AcceptanceRouter
|
|
43
|
+
|
|
44
|
+
include Rigor::ValueSemantics
|
|
45
|
+
|
|
46
|
+
value_fields :ok_type, :err_type
|
|
47
|
+
|
|
48
|
+
def inspect
|
|
49
|
+
"#<Rigor::Type::Result #{describe(:short)}>"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/rigor/type.rb
CHANGED
|
@@ -28,6 +28,8 @@ require_relative "type/difference"
|
|
|
28
28
|
require_relative "type/refined"
|
|
29
29
|
require_relative "type/intersection"
|
|
30
30
|
require_relative "type/app"
|
|
31
|
+
require_relative "type/result"
|
|
32
|
+
require_relative "type/maybe"
|
|
31
33
|
require_relative "type/bound_method"
|
|
32
34
|
require_relative "type/accepts_result"
|
|
33
35
|
require_relative "type/combinator"
|
data/lib/rigor/version.rb
CHANGED
|
@@ -56,7 +56,9 @@ module Rigor
|
|
|
56
56
|
# not ActionCable — pass through silently to avoid flagging unrelated `broadcast_to` methods.
|
|
57
57
|
return [] unless class_name.end_with?("Channel")
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
# `ChannelIndex#find` de-roots the query itself (#621) — a `::ChatChannel.broadcast_to` receiver
|
|
60
|
+
# and a plain `ChatChannel.broadcast_to` reach the same entry with no retry arm here.
|
|
61
|
+
entry = channel_index.find(class_name)
|
|
60
62
|
return [unknown_channel_violation(class_name, channel_index)] if entry.nil?
|
|
61
63
|
|
|
62
64
|
[broadcast_target_info(entry)]
|
|
@@ -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
|