rigortype 0.3.6 → 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 +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/manual/02-cli-reference.md +16 -7
- 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 +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/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 +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 +93 -1
- 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 +147 -10
- 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/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/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 +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.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/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/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/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
- metadata +69 -3
|
@@ -6,12 +6,14 @@ require_relative "railties/effects"
|
|
|
6
6
|
|
|
7
7
|
module Rigor
|
|
8
8
|
module Plugin
|
|
9
|
-
# rigor-railties — the Rails **framework core
|
|
9
|
+
# rigor-railties — the Rails **framework core**: its effect vocabulary, and the type of the four
|
|
10
|
+
# `Rails.` singleton readers.
|
|
10
11
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
12
|
+
# It emits no diagnostic and declares no producer. What it contributes is the part of ADR-103's Rails
|
|
13
|
+
# layer that belongs to no single component gem — `Rails.cache`, `Rails.logger`, `Rails.env`, the
|
|
14
|
+
# application configuration and the credentials — plus the `rails` entry-point preset that
|
|
15
|
+
# `effects.snapshot.reach:` adopts by name, plus the lenient nominals those same readers return
|
|
16
|
+
# ({RAILS_SINGLETON_READER_TYPES}).
|
|
15
17
|
#
|
|
16
18
|
# ## Why it is its own plugin
|
|
17
19
|
#
|
|
@@ -38,15 +40,23 @@ module Rigor
|
|
|
38
40
|
#
|
|
39
41
|
# ## Cost when effects are off
|
|
40
42
|
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
43
|
+
# The manifest's effect fields are frozen arrays; nothing reads them unless the project has an
|
|
44
|
+
# `effects:` block ({Rigor::Plugin::Registry#effect_contributions} is lazy). The reader typing costs one
|
|
45
|
+
# `ContributionIndex` name-gate probe per dispatch of a method actually named `logger` / `cache` /
|
|
46
|
+
# `configuration` / `application` (ADR-52 WD1 compiles the `methods:` gate into the registry), and the
|
|
47
|
+
# block declines on the first check for anything whose receiver is not the `Rails` constant. The reader
|
|
48
|
+
# gate ({#project_defined_reader?}) runs only after that syntactic gate passes, so it is confined to
|
|
49
|
+
# `Rails.`-shaped sites. It runs identically for both spellings — bare `Rails` and root-qualified
|
|
50
|
+
# `::Rails` both resolve through `Scope#type_of` ({#resolved_rails_constant_name}), which the engine's
|
|
51
|
+
# own rooted constant resolution (#614) answers correctly on its own.
|
|
44
52
|
class Railties < Rigor::Plugin::Base
|
|
45
53
|
manifest(
|
|
46
54
|
id: "railties",
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
# Bumped 2026-09-01 (#534 item 2) — the four `Rails.` singleton readers now return lenient
|
|
56
|
+
# nominals instead of `Dynamic[top]`.
|
|
57
|
+
version: "0.2.0",
|
|
58
|
+
description: "The Rails framework-core effect vocabulary and singleton-reader types: cache, " \
|
|
59
|
+
"logger, environment, configuration, credentials, and the `rails` entry-point preset.",
|
|
50
60
|
# ADR-103 WD2 — rigor-railties models Rails itself, so it opens the `rails.*` root. Granted only
|
|
51
61
|
# because the engine bundles this plugin ({Rigor::Plugin::FirstParty}); the same declaration from a
|
|
52
62
|
# third-party gem would open `railties.*` and earn a warning.
|
|
@@ -55,6 +65,141 @@ module Rigor
|
|
|
55
65
|
effect_attributions: Effects.attributions,
|
|
56
66
|
effect_entry_points: Effects.entry_points
|
|
57
67
|
)
|
|
68
|
+
|
|
69
|
+
# Issue #534 item 2 — the four `Rails.` singleton readers, measured on the 2026-09-01 corpus sweep as
|
|
70
|
+
# 261 opaque sites on mastodon alone (`configuration` 108, `cache` 81, `logger` 50, `application` 22).
|
|
71
|
+
# Every one of them typed `Dynamic[top]`, so the whole `Rails.cache.fetch(...)` /
|
|
72
|
+
# `Rails.application.config...` surface was unprotected.
|
|
73
|
+
#
|
|
74
|
+
# Each value is a **lenient nominal**: a real Rails class name for which Rigor ships NO RBS. That is
|
|
75
|
+
# the same construction rigor-actionpack's `REQUEST_CONTEXT_READER_TYPES` uses, and it is chosen for
|
|
76
|
+
# the same reason — the site becomes a *concrete* receiver (`coverage --protection` counts it, the
|
|
77
|
+
# dispatch resolves against a named class) while the method surface stays engine-lenient, because
|
|
78
|
+
# `call.undefined-method` and the argument / arity rules all bail at
|
|
79
|
+
# `Rigor::Reflection.rbs_class_known?`. Shipping a partial RBS for any of these would invert that:
|
|
80
|
+
# a declared class drops every member its RBS omits, and `Rails.logger.tagged { }` becomes a false
|
|
81
|
+
# positive.
|
|
82
|
+
#
|
|
83
|
+
# ## Why these names, and why not `::Logger`
|
|
84
|
+
#
|
|
85
|
+
# - `logger` → **`ActiveSupport::BroadcastLogger`**, not `::Logger`. `::Logger` is the name the issue
|
|
86
|
+
# suggested and it is the one name here that must NOT be used: it is a stdlib class, so Rigor knows
|
|
87
|
+
# its RBS, so the leniency argument above evaporates and every ActiveSupport extension of the logger
|
|
88
|
+
# — `tagged`, `silence`, `broadcast_to`, `local_level=` — becomes `call.undefined-method` on working
|
|
89
|
+
# Rails code. Measured, not assumed (spec: "does not fire undefined-method on the ActiveSupport
|
|
90
|
+
# logger surface"). `BroadcastLogger` is what `Rails.logger` actually returns on Rails 7.1+, which
|
|
91
|
+
# is every Rails still supported; on 7.0 and earlier it was an `ActiveSupport::Logger`, and since
|
|
92
|
+
# both names are RBS-less the difference is invisible to every rule — the accurate modern name is
|
|
93
|
+
# preferred only so a project that later authors its own `sig/` for it gets the right class.
|
|
94
|
+
# - `cache` → `ActiveSupport::Cache::Store`, the declared return of `Rails.cache` and the base class
|
|
95
|
+
# of every store implementation.
|
|
96
|
+
# - `application` → `Rails::Application`, the singleton the railtie builds.
|
|
97
|
+
# - `configuration` → `Rails::Application::Configuration`. This is the entry whose *leniency* matters
|
|
98
|
+
# most rather than its accuracy: `config.x_service_url` works through `method_missing` into
|
|
99
|
+
# `@custom`, so no RBS could ever be complete for it, and the RBS-less nominal is the only answer
|
|
100
|
+
# that types the receiver without claiming to know the members.
|
|
101
|
+
#
|
|
102
|
+
# ## The nil question
|
|
103
|
+
#
|
|
104
|
+
# All four are non-nil nominals, and a non-nil type licenses the flow rules to fold `if Rails.logger`
|
|
105
|
+
# to always-truthy (the hazard that kept `Parameters#[]` out of rigor-actionpack's table, #578). The
|
|
106
|
+
# difference is that here the non-nil claim is TRUE: inside a booted application — the only state in
|
|
107
|
+
# which `app/` code runs — the railtie initializers have assigned all four, and `Rails.logger` is nil
|
|
108
|
+
# only in a bare `require "rails"` process. The paired specs pin both halves: no `undefined-method` on
|
|
109
|
+
# the extension surface, and no fold on a `Rails.cache.fetch { }` guard shape.
|
|
110
|
+
RAILS_SINGLETON_READER_TYPES = {
|
|
111
|
+
logger: "ActiveSupport::BroadcastLogger",
|
|
112
|
+
cache: "ActiveSupport::Cache::Store",
|
|
113
|
+
configuration: "Rails::Application::Configuration",
|
|
114
|
+
application: "Rails::Application"
|
|
115
|
+
}.freeze
|
|
116
|
+
|
|
117
|
+
# `logger` / `cache` / `configuration` / `application` are ordinary method names that any project may
|
|
118
|
+
# define on its own objects, so the receiver gate is syntactic and exact: the literal `Rails` constant,
|
|
119
|
+
# bare or root-qualified (`::Rails`). A `receivers:` gate cannot express it — the `Rails` constant is
|
|
120
|
+
# unresolved in a project with no Rails RBS, so the receiver type is `Dynamic` and
|
|
121
|
+
# `dynamic_return_receiver_class_name` yields nil. The one thing the type side CAN see is the opposite
|
|
122
|
+
# case — the project defining this very reader on its own `Rails` — and that is the second gate
|
|
123
|
+
# ({#project_defined_reader?}).
|
|
124
|
+
dynamic_return methods: RAILS_SINGLETON_READER_TYPES.keys do |call_node, scope|
|
|
125
|
+
next nil unless call_node.is_a?(Prism::CallNode)
|
|
126
|
+
next nil unless call_node.arguments.nil? # `Rails.logger`, not `Rails.logger(x)`
|
|
127
|
+
next nil unless call_node.block.nil?
|
|
128
|
+
next nil unless rails_constant_receiver?(call_node.receiver)
|
|
129
|
+
next nil if project_defined_reader?(call_node, scope)
|
|
130
|
+
|
|
131
|
+
class_name = RAILS_SINGLETON_READER_TYPES[call_node.name]
|
|
132
|
+
next nil if class_name.nil?
|
|
133
|
+
|
|
134
|
+
Rigor::Type::Combinator.nominal_of(class_name)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# ADR-88 WD1 — the reader types are a static table and the effect rows are built from the manifest, so
|
|
138
|
+
# the plugin holds no state of its own that a fingerprint would have to observe, and a project that
|
|
139
|
+
# enables it stays incremental-capable. The one project-derived input is the {#project_defined_reader?}
|
|
140
|
+
# gate's `discovered_methods` read, and that does NOT need to ride this sentinel: it goes through
|
|
141
|
+
# `Rigor::Reflection.discovered_method?`, which records the ADR-46 negative dependency on a miss, so a
|
|
142
|
+
# warm `--incremental` re-check invalidates the gated sites the same way the engine's own cross-file
|
|
143
|
+
# method reads are invalidated — per consumer and per symbol, not by refusing the snapshot wholesale.
|
|
144
|
+
def incremental_state_fingerprint
|
|
145
|
+
"static-rails-readers"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
# True for the `Rails` constant written as a receiver — `Rails.logger` (`ConstantReadNode`) and
|
|
151
|
+
# `::Rails.logger` (a root-qualified `ConstantPathNode`). A nested `Foo::Rails` or `::Foo::Rails` is
|
|
152
|
+
# deliberately NOT matched: it is a different constant, and matching it would type someone else's
|
|
153
|
+
# `logger`. `Source::ConstantPath.qualified_name_or_nil` already renders both accepted spellings down
|
|
154
|
+
# to the bare `"Rails"` string, and any multi-segment path down to its full dotted name, so the single
|
|
155
|
+
# equality check below is the whole gate (#626).
|
|
156
|
+
def rails_constant_receiver?(receiver)
|
|
157
|
+
Rigor::Source::ConstantPath.qualified_name_or_nil(receiver) == "Rails"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# #588 — the syntactic gate cannot tell the framework's `Rails` from a `Rails` the project declares
|
|
161
|
+
# itself, and when the project declares `def self.logger` on it, retyping the site to
|
|
162
|
+
# `BroadcastLogger` buries the project's own answer for its own constant. Listing this plugin beside
|
|
163
|
+
# such a definition is a self-contradictory configuration (activation is the `plugins:` list alone —
|
|
164
|
+
# no lockfile gate notices the framework is absent), but the project's definition is the honest type.
|
|
165
|
+
#
|
|
166
|
+
# The gate is deliberately the READER, not the constant. "The project has a `Rails` constant" is far
|
|
167
|
+
# too wide: a real Rails app reopens `module Rails` to hang an initializer or an engine extension off
|
|
168
|
+
# it (mastodon's `lib/rails/engine_extensions.rb`), and a compact `class Rails::HealthController`
|
|
169
|
+
# synthesizes the namespace on its own (#528) — in both shapes NOTHING answers `Rails.logger` but the
|
|
170
|
+
# framework, so declining there would only drop the site back to `Dynamic[top]` and give up #534's
|
|
171
|
+
# 261 mastodon sites for nothing. Declining only when the project defines the very method being
|
|
172
|
+
# called keeps the reopen typed and still yields the constant to whoever owns it.
|
|
173
|
+
#
|
|
174
|
+
# Diagnostics-silent either way when the project's definition exists (both nominals are RBS-less), so
|
|
175
|
+
# this half is precision-honesty rather than a false-positive fix; the reader-not-constant narrowing
|
|
176
|
+
# is what keeps it from being a precision regression.
|
|
177
|
+
def project_defined_reader?(call_node, scope)
|
|
178
|
+
owner = resolved_rails_constant_name(call_node.receiver, scope)
|
|
179
|
+
return false if owner.nil?
|
|
180
|
+
|
|
181
|
+
Rigor::Reflection.discovered_method?(owner, call_node.name, kind: :singleton, scope: scope)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The constant name this receiver ACTUALLY resolves to, or nil when it resolves to nothing the
|
|
185
|
+
# project declares (the ordinary case: the framework's `Rails` is unresolved without a Rails RBS, so
|
|
186
|
+
# it types `Dynamic` and no name comes back).
|
|
187
|
+
#
|
|
188
|
+
# Both spellings go through `Scope#type_of` — Ruby's lexical walk, `Module.nesting` innermost-first,
|
|
189
|
+
# then project ancestors, then top level ({Rigor::Reflection.resolve_constant_type}) — which is the
|
|
190
|
+
# engine's single implementation and not a second one here. A root-qualified `::Rails` used to be
|
|
191
|
+
# answered directly, bypassing the engine: `Source::ConstantPath.qualified_name_or_nil` renders a
|
|
192
|
+
# parent-less `ConstantPathNode` as the bare `"Rails"`, and before #614 `Scope#type_of` had no way to
|
|
193
|
+
# tell that path was rooted, so it walked LEXICALLY and answered `MyApp::Rails` for a `::Rails` written
|
|
194
|
+
# inside `module MyApp`. #614 taught the engine to carry rootedness alongside the name
|
|
195
|
+
# (`Reflection.resolve_constant_type(name, scope:, rooted:)`), so `::Rails` now resolves to the
|
|
196
|
+
# top-level constant through the very same call, and this method no longer needs its own copy (#626).
|
|
197
|
+
def resolved_rails_constant_name(receiver, scope)
|
|
198
|
+
return nil if scope.nil?
|
|
199
|
+
|
|
200
|
+
receiver_type = scope.type_of(receiver)
|
|
201
|
+
receiver_type.class_name if receiver_type.is_a?(Rigor::Type::Singleton)
|
|
202
|
+
end
|
|
58
203
|
end
|
|
59
204
|
|
|
60
205
|
Rigor::Plugin.register(Railties)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor"
|
|
4
|
+
require "rigor-ffi"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Plugin
|
|
8
|
+
class RbNaCl < Base
|
|
9
|
+
manifest(
|
|
10
|
+
id: "rbnacl",
|
|
11
|
+
version: "0.1.0",
|
|
12
|
+
description: "Rigor type support for RbNaCl libsodium bindings"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Issue 2 & 5: use robust positional binding extractor and preserve module context
|
|
16
|
+
ffi_binding_recognizer :sodium_function do |node, module_name|
|
|
17
|
+
next [] unless node.is_a?(Prism::CallNode) && node.name == :sodium_function
|
|
18
|
+
|
|
19
|
+
receiver = module_name && !module_name.empty? ? module_name : "RbNaCl"
|
|
20
|
+
fact = FFI::Analyzer.extract_function_binding(
|
|
21
|
+
node,
|
|
22
|
+
method_name: :sodium_function,
|
|
23
|
+
module_name: receiver,
|
|
24
|
+
default_return: :int
|
|
25
|
+
)
|
|
26
|
+
fact ? [fact] : []
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module RbNaCl
|
|
2
|
+
class SecretBox
|
|
3
|
+
def initialize: (String key) -> void
|
|
4
|
+
def encrypt: (String nonce, String message) -> String
|
|
5
|
+
def decrypt: (String nonce, String ciphertext) -> String
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
module Signatures
|
|
9
|
+
module Ed25519
|
|
10
|
+
class SigningKey
|
|
11
|
+
def initialize: (String seed) -> void
|
|
12
|
+
def sign: (String message) -> String
|
|
13
|
+
def verify_key: () -> VerifyKey
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class VerifyKey
|
|
17
|
+
def initialize: (String key) -> void
|
|
18
|
+
def verify: (String signature, String message) -> bool
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor/plugin"
|
|
4
|
+
require "rigor-ffi"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Plugin
|
|
8
|
+
class SassC < Base
|
|
9
|
+
manifest(
|
|
10
|
+
id: "sassc",
|
|
11
|
+
version: "0.1.0",
|
|
12
|
+
description: "Models SassC native bindings, nominal pointer typedefs, and struct unions.",
|
|
13
|
+
signature_paths: ["sig"]
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Recognizer for sass prefix-stripped attach_function calls
|
|
17
|
+
ffi_binding_recognizer :sassc_function do |node, module_name|
|
|
18
|
+
next [] unless node.is_a?(Prism::CallNode) && node.name == :attach_function
|
|
19
|
+
|
|
20
|
+
args = node.arguments&.arguments || []
|
|
21
|
+
next [] if args.empty?
|
|
22
|
+
|
|
23
|
+
ruby_name = FFI::Analyzer.extract_symbol(args[0])
|
|
24
|
+
next [] if ruby_name.nil?
|
|
25
|
+
|
|
26
|
+
mod = module_name && !module_name.empty? ? module_name : "SassC::Native"
|
|
27
|
+
|
|
28
|
+
# If ruby_name starts with sass_, also register stripped version
|
|
29
|
+
c_name = ruby_name
|
|
30
|
+
stripped_name = ruby_name.to_s.sub(/^sass_/, "").to_sym
|
|
31
|
+
|
|
32
|
+
fact = FFI::Analyzer.extract_attach_function(node, module_name: mod)
|
|
33
|
+
next [] if fact.nil?
|
|
34
|
+
|
|
35
|
+
if stripped_name == ruby_name
|
|
36
|
+
[fact]
|
|
37
|
+
else
|
|
38
|
+
[
|
|
39
|
+
fact,
|
|
40
|
+
FFI::AttachFunctionFact.new(
|
|
41
|
+
ruby_name: stripped_name,
|
|
42
|
+
c_name: c_name,
|
|
43
|
+
arg_types: fact.arg_types,
|
|
44
|
+
return_type: fact.return_type,
|
|
45
|
+
node: node,
|
|
46
|
+
receiver_name: mod
|
|
47
|
+
)
|
|
48
|
+
]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
Rigor::Plugin.register(Rigor::Plugin::SassC)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module SassC
|
|
2
|
+
class Engine
|
|
3
|
+
def initialize: (String template, ?Hash[Symbol, untyped] options) -> void
|
|
4
|
+
def render: () -> String
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
module Native
|
|
8
|
+
class SassDataContextPtr < FFI::Pointer
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class SassOptionsPtr < FFI::Pointer
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class SassContextPtr < FFI::Pointer
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.make_data_context: (String src) -> SassDataContextPtr
|
|
18
|
+
def self.compile_data_context: (SassDataContextPtr dctx) -> Integer
|
|
19
|
+
def self.data_context_get_options: (SassDataContextPtr dctx) -> SassOptionsPtr
|
|
20
|
+
def self.data_context_get_context: (SassDataContextPtr dctx) -> SassContextPtr
|
|
21
|
+
def self.context_get_output_string: (SassContextPtr ctx) -> String
|
|
22
|
+
def self.delete_data_context: (SassDataContextPtr dctx) -> void
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -30,8 +30,8 @@ module Rigor
|
|
|
30
30
|
# have_db_column(:col)
|
|
31
31
|
# have_db_index(:col)
|
|
32
32
|
#
|
|
33
|
-
# All look up `:col` against the model's columns (`
|
|
34
|
-
# `unknown-column`.
|
|
33
|
+
# All look up `:col` against the model's `columns:` (the published `:model_index` fact's flat
|
|
34
|
+
# `Array<String>` per model — see `Analyzer#column_names`). Unknown columns fire `unknown-column`.
|
|
35
35
|
#
|
|
36
36
|
# ### Association matchers
|
|
37
37
|
#
|
|
@@ -74,7 +74,10 @@ module Rigor
|
|
|
74
74
|
|
|
75
75
|
# @param path [String]
|
|
76
76
|
# @param root [Prism::Node]
|
|
77
|
-
# @param model_index [
|
|
77
|
+
# @param model_index [Hash{String => Hash}, nil] the `:model_index` fact value — the flat
|
|
78
|
+
# `class_name => { table:, columns:, associations:, ... }` Hash `rigor-activerecord` publishes
|
|
79
|
+
# (ADR-9's "the value is data, not objects" contract; see `Activerecord#index_to_published_hash`).
|
|
80
|
+
# When nil the analyzer falls silent.
|
|
78
81
|
# @return [Array<Diagnostic>]
|
|
79
82
|
# The matcher violations for a single call node (0..1), or `[]` when it is not a shoulda matcher,
|
|
80
83
|
# sits outside any `describe <ModelConst>` block, or the model is unknown. ADR-37: the engine owns
|
|
@@ -84,7 +87,7 @@ module Rigor
|
|
|
84
87
|
#
|
|
85
88
|
# @param matcher_call [Prism::Node]
|
|
86
89
|
# @param ancestors [Array<Prism::Node>]
|
|
87
|
-
# @param model_index [
|
|
90
|
+
# @param model_index [Hash{String => Hash}, nil]
|
|
88
91
|
# @return [Array<Violation>]
|
|
89
92
|
def violations_for(matcher_call:, ancestors:, model_index:)
|
|
90
93
|
return [] if model_index.nil?
|
|
@@ -93,7 +96,10 @@ module Rigor
|
|
|
93
96
|
anchor = anchor_for(ancestors)
|
|
94
97
|
return [] if anchor.nil?
|
|
95
98
|
|
|
96
|
-
|
|
99
|
+
# Keyed Hash lookup — NOT `ModelIndex#find`. The published fact is a plain Hash (`class_name =>
|
|
100
|
+
# entry Hash`), never the `ModelIndex` object `rigor-activerecord` builds internally (issue #573:
|
|
101
|
+
# `Hash#find(anchor)` with no block returns an Enumerator, not the entry).
|
|
102
|
+
entry = model_index[anchor]
|
|
97
103
|
return [] if entry.nil?
|
|
98
104
|
|
|
99
105
|
violation = diagnostic_for(matcher_call, anchor, entry)
|
|
@@ -137,6 +143,9 @@ module Rigor
|
|
|
137
143
|
render_constant_path(arg)
|
|
138
144
|
end
|
|
139
145
|
|
|
146
|
+
# The anchor is the constant path WITHOUT a leading `::` — `describe ::User` anchors on `"User"`,
|
|
147
|
+
# the same spelling `describe User` produces — because the `:model_index` fact is keyed by the
|
|
148
|
+
# de-rooted class name however the model was declared (#583; `Activerecord#index_to_published_hash`).
|
|
140
149
|
def render_constant_path(node)
|
|
141
150
|
case node
|
|
142
151
|
when Prism::ConstantReadNode then node.name.to_s
|
|
@@ -148,7 +157,7 @@ module Rigor
|
|
|
148
157
|
current = current.parent
|
|
149
158
|
end
|
|
150
159
|
case current
|
|
151
|
-
when nil then
|
|
160
|
+
when nil then parts.join("::")
|
|
152
161
|
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
153
162
|
end
|
|
154
163
|
end
|
|
@@ -170,19 +179,30 @@ module Rigor
|
|
|
170
179
|
end
|
|
171
180
|
end
|
|
172
181
|
|
|
182
|
+
# `entry` is the published fact's per-model Hash: `{ table:, columns:, associations:, ... }`.
|
|
183
|
+
# `columns:` is a flat `Array<String>` (`Activerecord#index_to_published_hash`); `associations:` is
|
|
184
|
+
# `Array<Hash>` with String `:name`, Symbol `:kind` (`:singular` | `:collection`), `:nullable`,
|
|
185
|
+
# `:polymorphic`, and a `:target` that is a String except on polymorphic rows, where it is nil —
|
|
186
|
+
# the row shape `ModelDiscoverer#build_association_row` publishes. Only `:name` and `:kind` are
|
|
187
|
+
# read here.
|
|
188
|
+
def column_names(entry) = entry[:columns] || []
|
|
189
|
+
def association_rows(entry) = entry[:associations] || []
|
|
190
|
+
def find_association(entry, name) = association_rows(entry).find { |a| a[:name] == name.to_s }
|
|
191
|
+
|
|
173
192
|
def column_violation(matcher_call, anchor, entry, column_name)
|
|
174
|
-
return nil if entry.
|
|
193
|
+
return nil if column_names(entry).include?(column_name.to_s)
|
|
175
194
|
|
|
176
195
|
Violation.new(
|
|
177
196
|
rule: "unknown-column",
|
|
178
197
|
message: "#{matcher_call.name}(:#{column_name}) — no column `#{column_name}` on " \
|
|
179
|
-
"#{anchor} (columns: #{entry.
|
|
198
|
+
"#{anchor} (columns: #{column_names(entry).sort.join(', ')})"
|
|
180
199
|
)
|
|
181
200
|
end
|
|
182
201
|
|
|
183
202
|
def association_violation(matcher_call, anchor, entry, assoc_name, expected_kind:)
|
|
184
|
-
|
|
185
|
-
|
|
203
|
+
association = find_association(entry, assoc_name)
|
|
204
|
+
if association
|
|
205
|
+
actual = association[:kind]
|
|
186
206
|
return nil if actual == expected_kind
|
|
187
207
|
|
|
188
208
|
Violation.new(
|
|
@@ -191,10 +211,11 @@ module Rigor
|
|
|
191
211
|
"a #{actual} association; #{matcher_call.name} expects #{expected_kind}"
|
|
192
212
|
)
|
|
193
213
|
else
|
|
214
|
+
known = association_rows(entry).map { |a| a[:name] }
|
|
194
215
|
Violation.new(
|
|
195
216
|
rule: "unknown-association",
|
|
196
217
|
message: "#{matcher_call.name}(:#{assoc_name}) — no association `#{assoc_name}` on " \
|
|
197
|
-
"#{anchor} (associations: #{
|
|
218
|
+
"#{anchor} (associations: #{known.sort.join(', ')})"
|
|
198
219
|
)
|
|
199
220
|
end
|
|
200
221
|
end
|
|
@@ -44,7 +44,9 @@ module Rigor
|
|
|
44
44
|
class_name = constant_receiver_name(call_node.receiver)
|
|
45
45
|
return [] if class_name.nil?
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
# `WorkerIndex#find` de-roots the query itself (#621) — a `::WelcomeWorker.perform_async` receiver
|
|
48
|
+
# and a plain `WelcomeWorker.perform_async` reach the same entry with no retry arm here.
|
|
49
|
+
entry = worker_index.find(class_name)
|
|
48
50
|
return [] if entry.nil?
|
|
49
51
|
|
|
50
52
|
violations = [info_violation(call_node, entry)]
|
|
@@ -68,7 +68,10 @@ module Rigor
|
|
|
68
68
|
|
|
69
69
|
def load_document(path)
|
|
70
70
|
absolute = File.expand_path(path.to_s)
|
|
71
|
-
|
|
71
|
+
# ADR-45 WD1b (#613) — the probe goes through the boundary so "there is no `config/sidekiq.yml`"
|
|
72
|
+
# is a recorded dependency; `File.file?` here left the warm run serving the no-schedule answer
|
|
73
|
+
# after the file appeared.
|
|
74
|
+
return nil unless @io_boundary.file?(absolute)
|
|
72
75
|
|
|
73
76
|
contents = read_safely(absolute)
|
|
74
77
|
contents && parse_safely(contents)
|
|
@@ -27,26 +27,60 @@ module Rigor
|
|
|
27
27
|
def initialize(io_boundary:, search_paths:, marker_modules:)
|
|
28
28
|
@io_boundary = io_boundary
|
|
29
29
|
@search_paths = search_paths
|
|
30
|
-
|
|
30
|
+
# De-rooted so the marker match is by exact spelling on both sides: a worker written
|
|
31
|
+
# `include ::Sidekiq::Job` — and a configured `worker_marker_modules: ["::Sidekiq::Job"]` —
|
|
32
|
+
# would otherwise never match anything the other side can render.
|
|
33
|
+
@marker_modules = marker_modules.to_set { |name| strip_root(name.to_s) }
|
|
31
34
|
end
|
|
32
35
|
|
|
33
36
|
# @return [WorkerIndex]
|
|
34
37
|
def discover
|
|
35
|
-
|
|
38
|
+
candidates = []
|
|
36
39
|
ruby_files_under(@search_paths).each do |path|
|
|
37
40
|
contents = read_safely(path)
|
|
38
41
|
next if contents.nil?
|
|
39
42
|
|
|
40
43
|
tree = Prism.parse(contents).value
|
|
41
44
|
walk_for_workers(tree, []) do |class_name, perform_def|
|
|
42
|
-
|
|
45
|
+
candidates << [class_name, perform_def]
|
|
43
46
|
end
|
|
44
47
|
end
|
|
45
|
-
WorkerIndex.new(
|
|
48
|
+
WorkerIndex.new(merge_redeclarations(candidates))
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
private
|
|
49
52
|
|
|
53
|
+
# Collapses the candidates that declare the SAME constant into one entry, so {WorkerIndex} — which
|
|
54
|
+
# keys its rows by `class_name` — receives at most one per worker.
|
|
55
|
+
#
|
|
56
|
+
# A worker is routinely declared more than once: the real class in `app/workers/welcome_worker.rb`
|
|
57
|
+
# and a second file reopening it (`class ::WelcomeWorker`, `class WelcomeWorker`) to add a method.
|
|
58
|
+
# Taking the last candidate dropped the real `#perform` envelope whenever the reopen sorted later in
|
|
59
|
+
# the glob — the reopen carries no `#perform`, so the worker silently became any-arity and every
|
|
60
|
+
# `perform_async` went unchecked. The rows are merged instead:
|
|
61
|
+
#
|
|
62
|
+
# - The declarations that actually spell `def perform` decide the envelope; a reopen that does not
|
|
63
|
+
# contributes nothing rather than erasing it.
|
|
64
|
+
# - When two declarations BOTH spell it with different shapes, the envelope WIDENS (min of the mins,
|
|
65
|
+
# max of the maxes). The glob order is not the load order, so pinning either shape would surface
|
|
66
|
+
# `wrong-arity` on a call the definition Ruby actually loads accepts — ADR-5: a missed narrow case
|
|
67
|
+
# costs precision, a wrong one costs correct code.
|
|
68
|
+
def merge_redeclarations(candidates)
|
|
69
|
+
candidates.group_by(&:first).map do |class_name, rows|
|
|
70
|
+
declared = rows.filter_map { |(_, perform_def)| perform_def }
|
|
71
|
+
entries = declared.empty? ? [build_entry(class_name, nil)] : declared.map { |d| build_entry(class_name, d) }
|
|
72
|
+
entries.reduce { |base, addition| widen(base, addition) }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The arity envelope that accepts every call either declaration accepts. See {#merge_redeclarations}.
|
|
77
|
+
def widen(base, addition)
|
|
78
|
+
base.with(
|
|
79
|
+
min_arity: [base.min_arity, addition.min_arity].min,
|
|
80
|
+
max_arity: [base.max_arity, addition.max_arity].max
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
50
84
|
def read_safely(path)
|
|
51
85
|
@io_boundary.read_file(path)
|
|
52
86
|
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
@@ -56,7 +90,8 @@ module Rigor
|
|
|
56
90
|
def ruby_files_under(roots)
|
|
57
91
|
roots.flat_map do |root|
|
|
58
92
|
absolute = File.expand_path(root)
|
|
59
|
-
|
|
93
|
+
# ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
|
|
94
|
+
next [] unless @io_boundary.directory?(absolute)
|
|
60
95
|
|
|
61
96
|
Dir.glob(File.join(absolute, "**", "*.rb"))
|
|
62
97
|
end
|
|
@@ -77,24 +112,40 @@ module Rigor
|
|
|
77
112
|
class_local_name = constant_path_name(node.constant_path)
|
|
78
113
|
return if class_local_name.nil?
|
|
79
114
|
|
|
80
|
-
full_name = (lexical_path
|
|
115
|
+
full_name = declared_constant_name(class_local_name, lexical_path)
|
|
81
116
|
if includes_marker_module?(node.body)
|
|
82
117
|
perform_def = lookup_perform_def(node.body)
|
|
83
118
|
yield full_name, perform_def
|
|
84
119
|
end
|
|
85
120
|
|
|
86
|
-
|
|
87
|
-
walk_for_workers(node.body, inner_path, &) if node.body
|
|
121
|
+
walk_for_workers(node.body, [full_name], &) if node.body
|
|
88
122
|
end
|
|
89
123
|
|
|
90
124
|
def visit_module(node, lexical_path, &)
|
|
91
125
|
module_local_name = constant_path_name(node.constant_path)
|
|
92
126
|
return if module_local_name.nil?
|
|
93
127
|
|
|
94
|
-
inner_path =
|
|
128
|
+
inner_path = [declared_constant_name(module_local_name, lexical_path)]
|
|
95
129
|
walk_for_workers(node.body, inner_path, &) if node.body
|
|
96
130
|
end
|
|
97
131
|
|
|
132
|
+
# The full constant name a `class` / `module` declaration defines, given the rendered local name
|
|
133
|
+
# and the enclosing lexical path. A ROOTED local name (`class ::WelcomeWorker`) names the top-level
|
|
134
|
+
# constant whatever the nesting, so the lexical path is dropped and the `::` with it; otherwise the
|
|
135
|
+
# name is appended to the path (`class WelcomeWorker` inside `module Admin` →
|
|
136
|
+
# `Admin::WelcomeWorker`).
|
|
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
|
+
# `::WelcomeWorker` → `WelcomeWorker`; a name without the root marker is returned unchanged (nil
|
|
144
|
+
# stays nil).
|
|
145
|
+
def strip_root(name)
|
|
146
|
+
name&.delete_prefix("::")
|
|
147
|
+
end
|
|
148
|
+
|
|
98
149
|
# Returns true if the class body contains a top-level `include <Module>` call where `<Module>`
|
|
99
150
|
# matches one of the configured marker modules.
|
|
100
151
|
def includes_marker_module?(body)
|
|
@@ -106,11 +157,13 @@ module Rigor
|
|
|
106
157
|
next false unless node.receiver.nil?
|
|
107
158
|
|
|
108
159
|
arg = node.arguments&.arguments&.first
|
|
109
|
-
module_name = constant_path_name(arg)
|
|
160
|
+
module_name = strip_root(constant_path_name(arg))
|
|
110
161
|
module_name && @marker_modules.include?(module_name)
|
|
111
162
|
end
|
|
112
163
|
end
|
|
113
164
|
|
|
165
|
+
# Renders a constant-path node as a String, KEEPING the leading `::` of a rooted path —
|
|
166
|
+
# {#declared_constant_name} reads it as "reset the lexical path" before the marker is dropped.
|
|
114
167
|
def constant_path_name(node)
|
|
115
168
|
return nil if node.nil?
|
|
116
169
|
|
|
@@ -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 worker would replace the first outright. {WorkerDiscoverer#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 (`"WelcomeWorker"`, `"Admin::WelcomeWorker"` —
|
|
39
|
+
# never `"::WelcomeWorker"`; see {WorkerDiscoverer}), while a QUERY may legitimately arrive rooted:
|
|
40
|
+
# `::WelcomeWorker.perform_async(1)` renders its receiver as `"::WelcomeWorker"`. 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?
|
|
@@ -52,6 +60,11 @@ module Rigor
|
|
|
52
60
|
def names
|
|
53
61
|
@by_name.keys
|
|
54
62
|
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# `::WelcomeWorker` → `WelcomeWorker`. The query-side half of the key contract — see {#find}.
|
|
67
|
+
def strip_leading_namespace(name) = name.delete_prefix("::")
|
|
55
68
|
end
|
|
56
69
|
end
|
|
57
70
|
end
|