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
|
@@ -30,7 +30,8 @@ errors_demo.rb:24:1: error: `User.find` expects at least 1 argument, got 0 [plug
|
|
|
30
30
|
| Recognised `Model.find` / `Model.find_by` / `Model.where` call | `:info` | `plugin.activerecord.model-call` |
|
|
31
31
|
| `Model.find_by(unknown: ...)` / `Model.where(unknown: ...)` | `:error` | `plugin.activerecord.unknown-column` |
|
|
32
32
|
| `Model.find` with 0 args | `:error` | `plugin.activerecord.wrong-arity` |
|
|
33
|
-
| No schema source (`db/schema.rb` or `db/structure.sql`)
|
|
33
|
+
| No schema source (`db/schema.rb` or `db/structure.sql`) present — reduced mode | `:info` | `plugin.activerecord.load-error` |
|
|
34
|
+
| A schema source that exists but cannot be read or parsed | `:warning` | `plugin.activerecord.load-error` |
|
|
34
35
|
|
|
35
36
|
Did-you-mean suggestions use `DidYouMean` fuzzy matching against
|
|
36
37
|
the resolved table's column names.
|
|
@@ -75,18 +76,90 @@ Chained query methods keep the element type, and iteration
|
|
|
75
76
|
scope invoked on a typed relation (`User.where(...).published`)
|
|
76
77
|
never surfaces a false `call.undefined-method`.
|
|
77
78
|
|
|
79
|
+
`User.table_name` types as `String`, and as the exact string
|
|
80
|
+
only when your source says the name: a literal
|
|
81
|
+
`self.table_name = "people"` on the class or on an STI ancestor,
|
|
82
|
+
with nothing in that chain computing the name at runtime (a
|
|
83
|
+
`def self.table_name`, a `class << self` version of it, or an
|
|
84
|
+
interpolated assignment all count as computing it). Every other
|
|
85
|
+
name — anything the plugin derived by pluralizing the class name —
|
|
86
|
+
stays plain `String`.
|
|
87
|
+
|
|
88
|
+
That includes names that look confirmed. A `users` table in your
|
|
89
|
+
schema is not evidence that it is `User`'s table: with a
|
|
90
|
+
`self.table_name_prefix` on the base class, `User` really reads
|
|
91
|
+
`app_users`, and a `users` table belonging to some other model
|
|
92
|
+
would "confirm" the wrong guess. A wrong exact string is worse
|
|
93
|
+
than an honest `String` — code comparing `User.table_name` would
|
|
94
|
+
quietly take the wrong branch — so the plugin pins only what you
|
|
95
|
+
wrote down. `User.quoted_table_name` is always `String`; the
|
|
96
|
+
quoting is up to the database adapter.
|
|
97
|
+
|
|
98
|
+
A model declared inside a Ruby module or class (`Blog::Post`)
|
|
99
|
+
resolves its table the way Rails does for the cases below: the
|
|
100
|
+
namespace is dropped, not flattened into the name, so `Blog::Post`
|
|
101
|
+
reads `posts`, not `blog_posts`. A `table_name_prefix` /
|
|
102
|
+
`table_name_suffix` the enclosing namespace declares as a literal
|
|
103
|
+
(`def self.table_name_prefix = "blog_"`, `class << self` with the
|
|
104
|
+
same, or `mattr_accessor :table_name_prefix, default: "blog_"`) is
|
|
105
|
+
applied on top, so the same model reads `blog_posts` once `Blog`
|
|
106
|
+
sets that. `mattr_writer` does not count — it defines no reader, so
|
|
107
|
+
Rails never actually reads the value back, and neither does the
|
|
108
|
+
plugin.
|
|
109
|
+
|
|
110
|
+
`Blog::Post.table_name` still reads as the plain demodulized name
|
|
111
|
+
(`posts`) when `Blog`'s prefix/suffix is declared in a shape the
|
|
112
|
+
plugin cannot read as a literal (a computed value, two disagreeing
|
|
113
|
+
declarations) — but that string is informational only in this case.
|
|
114
|
+
The plugin does not trust it enough to look up columns against it:
|
|
115
|
+
guessing a bare name is the guess most likely to hit an unrelated
|
|
116
|
+
REAL table in a namespaced app, and a wrong corroboration is worse
|
|
117
|
+
than none, so `Blog::Post`'s column, alias and association checks
|
|
118
|
+
stand down entirely rather than run against a table that might not
|
|
119
|
+
be the real one.
|
|
120
|
+
|
|
78
121
|
## Limitations
|
|
79
122
|
|
|
80
123
|
- **Direct-superclass match only.** `class Admin < User` where
|
|
81
124
|
`User < ApplicationRecord` is not discovered. Either add `User`
|
|
82
125
|
to `model_base_classes`, or list every concrete model
|
|
83
126
|
explicitly.
|
|
127
|
+
- **A model nested inside ANOTHER non-abstract model class stands down rather
|
|
128
|
+
than guesses.** `Post::Comment` where `Post < ApplicationRecord` and `Post`
|
|
129
|
+
is not abstract hits a different Rails naming rule entirely — the parent's own
|
|
130
|
+
table name is spliced into the middle of the child's, not a
|
|
131
|
+
prefix/suffix — so the plugin recognises the shape and stands
|
|
132
|
+
`Comment`'s column / alias / association checks down instead of
|
|
133
|
+
computing (or guessing at) the real name. (A model nested inside
|
|
134
|
+
an *abstract* parent class, such as `Base::Comment` where `Base` declares
|
|
135
|
+
`self.abstract_class = true` or `primary_abstract_class`, correctly resolves
|
|
136
|
+
its plain demodulized table name with full column checks.)
|
|
137
|
+
- **External `table_name_prefix` / `table_name_suffix` declarations and
|
|
138
|
+
engines.** Declarations outside `model_search_paths` (e.g. in `lib/` or an
|
|
139
|
+
engine's `isolate_namespace`) are detected across the project and cause
|
|
140
|
+
affected models to safely stand down with an empty column set, rather than
|
|
141
|
+
guessing an incorrect table name. Within `model_search_paths`, model-level
|
|
142
|
+
and base-class `table_name_prefix` declarations (literal or computed) are
|
|
143
|
+
resolved directly.
|
|
84
144
|
- **PostgreSQL `db/structure.sql` fallback.** When `db/schema.rb` is
|
|
85
145
|
absent, the plugin parses `db/structure.sql` (the `schema_format =
|
|
86
146
|
:sql` dump) for the same column/type table. It reads PostgreSQL DDL
|
|
87
147
|
only; a column whose SQL type has no Ruby mapping (a custom enum,
|
|
88
148
|
`tsvector`, `ltree`) degrades to `Object` (never dropped), and
|
|
89
149
|
non-`public`-schema partition tables are skipped.
|
|
150
|
+
- **No committed schema — reduced mode.** A project that ships raw
|
|
151
|
+
migrations and gitignores `db/schema.rb` (the DB-agnostic Rails
|
|
152
|
+
pattern) still gets table names, finders, scopes and associations:
|
|
153
|
+
those are read from your model source, not from the schema. Only
|
|
154
|
+
the column-dependent half stands down — column readers stay
|
|
155
|
+
untyped and `where(col:)` keys are not validated, exactly as they
|
|
156
|
+
are for a table the schema does not describe. The plugin says so
|
|
157
|
+
once per run at `:info`. Committing a schema dump (or pointing
|
|
158
|
+
`schema_file` / `structure_sql_file` at one) turns the column half
|
|
159
|
+
back on from the next cold run — a warm cache keeps serving the
|
|
160
|
+
reduced index until it is invalidated, so use `rigor check
|
|
161
|
+
--no-cache` (or `make cache-clean`) if you want to see the change
|
|
162
|
+
immediately.
|
|
90
163
|
- **Column reads, not setters.** The plugin types instance-side
|
|
91
164
|
column *reads* (`user.name`, `user.admin?`) and singular
|
|
92
165
|
associations, but not the `name=` setter or the dirty-tracking
|
|
@@ -44,7 +44,9 @@ Roughly the top ~40 selectors plus their close neighbours, across:
|
|
|
44
44
|
`#constantize`, `#pluralize`, …), filters (`#squish`, `#truncate`),
|
|
45
45
|
`#html_safe`, `#starts_with?` / `#ends_with?`, conversions.
|
|
46
46
|
- **Time / Date / DateTime** — `.current`, `.zone`, `#yesterday`,
|
|
47
|
-
`#tomorrow`, `#beginning_of_*` / `#end_of_*`, `#ago`, `#since`.
|
|
47
|
+
`#tomorrow`, `#beginning_of_*` / `#end_of_*`, `#ago`, `#since`. `Time`
|
|
48
|
+
additionally carries its **whole** Rails instance surface (see below);
|
|
49
|
+
`Date` and `DateTime` carry the same subset they always did.
|
|
48
50
|
- **Array** — `.wrap`, `#to_sentence`, `#in_groups_of`, `#second` …
|
|
49
51
|
`#fifth`, `#compact_blank`, `#exclude?`.
|
|
50
52
|
- **Hash** — `#symbolize_keys` / `#stringify_keys` (+ deep / bang),
|
|
@@ -57,25 +59,125 @@ Roughly the top ~40 selectors plus their close neighbours, across:
|
|
|
57
59
|
Time.current # without the bundle: call.undefined-method Time.current
|
|
58
60
|
```
|
|
59
61
|
|
|
62
|
+
## Durations are typed
|
|
63
|
+
|
|
64
|
+
`1.day`, `5.minutes`, `2.5.hours` and every other multiplier type as
|
|
65
|
+
`ActiveSupport::Duration`, and the arithmetic around them keeps its
|
|
66
|
+
meaning:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
1.day # ActiveSupport::Duration
|
|
70
|
+
Time.current - 30.minutes # Time
|
|
71
|
+
2 * 1.day # ActiveSupport::Duration
|
|
72
|
+
1.day + 1.hour # ActiveSupport::Duration
|
|
73
|
+
Date.today - 1.week # Date | Time
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`Date ± duration` is a union because that is what Rails does: a
|
|
77
|
+
date-part duration gives you back a `Date`, a sub-day one gives you a
|
|
78
|
+
`Time`.
|
|
79
|
+
|
|
80
|
+
Rigor ships a **partial** signature for `ActiveSupport::Duration`: the
|
|
81
|
+
reader surface — `#to_i` / `#in_seconds`, `#to_f`, `#in_minutes` /
|
|
82
|
+
`#in_hours` / `#in_days` / `#in_weeks` / `#in_months` / `#in_years`,
|
|
83
|
+
`#iso8601`, `#parts` — is typed, so `3.hours.in_minutes` is `Float` and
|
|
84
|
+
`1.day.to_i * 2` is `Integer`. `#ago` / `#until` / `#before` / `#since`
|
|
85
|
+
/ `#from_now` / `#after` are NOT part of that surface — they default
|
|
86
|
+
to `Time.current`, and typing them was blocked on Rails' `Time`
|
|
87
|
+
instance extensions being declared first, which the section below now
|
|
88
|
+
does; the multipliers themselves are tracked separately. Every
|
|
89
|
+
other member — the arithmetic operators above aside, `==`, and
|
|
90
|
+
anything else Duration forwards through `method_missing` — resolves
|
|
91
|
+
without a diagnostic too, while the site still counts as a concrete
|
|
92
|
+
receiver for `rigor coverage --protection`. Naming
|
|
93
|
+
`ActiveSupport::Duration` at all would normally be the wrong move — a
|
|
94
|
+
partial signature on a class whose real surface forwards to
|
|
95
|
+
`method_missing` turns every omitted member into a false
|
|
96
|
+
`call.undefined-method` — so the plugin lists it under
|
|
97
|
+
`open_receivers:`, the same exemption `rigor-activerecord` gives
|
|
98
|
+
`ActiveRecord::Relation`.
|
|
99
|
+
|
|
100
|
+
The multiplier only fires on a receiver Rigor has proven numeric, so
|
|
101
|
+
`created_at.day`, `Date.today.year` and your own object's `#days` keep
|
|
102
|
+
the answers they always had.
|
|
103
|
+
|
|
104
|
+
## The Rails `Time` instance surface is declared, not sampled
|
|
105
|
+
|
|
106
|
+
`Time` is a core Ruby class, so RBS knows it fully and it is **closed**:
|
|
107
|
+
a name the signatures do not declare is reported
|
|
108
|
+
`call.undefined-method`. That makes an omission on `Time` just as much
|
|
109
|
+
of a false positive as a wrong return type, with no gradual middle, so
|
|
110
|
+
this bundle declares the surface ActiveSupport adds by audit against the
|
|
111
|
+
gem's own sources rather than by a "top selectors" sample.
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
Time.current.to_fs(:db) # String
|
|
115
|
+
Time.current.formatted_offset # String
|
|
116
|
+
Time.current.past? # bool
|
|
117
|
+
Time.current.at_beginning_of_hour # Time
|
|
118
|
+
Time.current.days_ago(3).all_week # Range[Time]
|
|
119
|
+
Time.current.in_time_zone("Hawaii") # untyped (ActiveSupport::TimeWithZone)
|
|
120
|
+
Time.current.definitely_not_here # still call.undefined-method
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
That is the predicates (`#past?`, `#future?`, `#today?`, `#on_weekend?`,
|
|
124
|
+
…), the whole `#days_ago` / `#months_since` / `#next_occurring` family,
|
|
125
|
+
the quarter and `at_`-prefixed spellings, the `#all_week` / `#all_month`
|
|
126
|
+
/ `#all_quarter` / `#all_year` ranges, `#to_fs` / `#to_formatted_s` /
|
|
127
|
+
`#formatted_offset` / `#rfc3339`, `#in_time_zone`, and the `Time.`
|
|
128
|
+
singletons `.days_in_month`, `.days_in_year`, `.rfc3339`, `.use_zone`,
|
|
129
|
+
`.find_zone` / `.find_zone!` and `.zone_default`.
|
|
130
|
+
|
|
131
|
+
Where a return cannot honestly be named it is widened rather than
|
|
132
|
+
guessed: `#in_time_zone` answers an `ActiveSupport::TimeWithZone`, which
|
|
133
|
+
this bundle does not model, so it reads `untyped`.
|
|
134
|
+
|
|
135
|
+
What is left out is twelve names, measured against a real
|
|
136
|
+
`require "active_support/all"`: ten instance and two singleton, every one
|
|
137
|
+
an `alias_method` artefact of ActiveSupport's own `+` / `-` / `<=>` /
|
|
138
|
+
`eql?` / `Time.at` overrides — the `plus_with{,out}_duration`,
|
|
139
|
+
`minus_with{,out}_duration`, `minus_with{,out}_coercion`,
|
|
140
|
+
`compare_with{,out}_coercion`, `eql_with{,out}_coercion` and
|
|
141
|
+
`Time.at_with{,out}_coercion` pairs. They are public at runtime and
|
|
142
|
+
`:nodoc:` in the source, and nothing outside ActiveSupport calls them;
|
|
143
|
+
code that does will see them reported.
|
|
144
|
+
|
|
145
|
+
`Date` and `DateTime` are extended by the same ActiveSupport modules and
|
|
146
|
+
do **not** carry this yet — `Date.current.past?` still reports.
|
|
147
|
+
|
|
60
148
|
## No diagnostics, no config
|
|
61
149
|
|
|
62
|
-
The plugin
|
|
63
|
-
|
|
64
|
-
listed under `plugins:`.
|
|
150
|
+
The plugin emits no diagnostics and has no configuration knobs. It
|
|
151
|
+
contributes its signatures — and the Duration typing above —
|
|
152
|
+
unconditionally when listed under `plugins:`.
|
|
65
153
|
|
|
66
154
|
## Limitations
|
|
67
155
|
|
|
68
|
-
- **Conservative return types.** `
|
|
69
|
-
`
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
156
|
+
- **Conservative return types.** `#html_safe` is typed `String` (not
|
|
157
|
+
`SafeBuffer`) and `#try` / `#try!` return `untyped` — the goal for
|
|
158
|
+
those is to silence undefined-method, not to give precise returns.
|
|
159
|
+
(The Duration multipliers are one exception: they are declared
|
|
160
|
+
`untyped` in the bundle and then typed by the plugin instead — not
|
|
161
|
+
because the bundle can't name `ActiveSupport::Duration` (it does,
|
|
162
|
+
described above), but because moving the multiplier return itself
|
|
163
|
+
into RBS to match hasn't happened yet. `ActiveSupport::Duration`'s
|
|
164
|
+
own reader surface is the other exception, described above.)
|
|
165
|
+
- **`duration / x` is not typed.** `1.day / 2` is a Duration but
|
|
166
|
+
`1.day / 1.hour` is a plain `24`; the answer depends on the operand,
|
|
167
|
+
so Rigor declines rather than guessing.
|
|
168
|
+
- **`duration + Time` is not typed either.** `30.minutes + Time.now`
|
|
169
|
+
raises at runtime — `Duration#+` cannot coerce a Time, and `-`, `*`,
|
|
170
|
+
and a `Date` or `DateTime` on the right fail the same way — so Rigor
|
|
171
|
+
claims nothing for it. `Time.now + 30.minutes` is the form that has a
|
|
172
|
+
value, and it is typed `Time`.
|
|
74
173
|
- **Project-private monkey-patches are not covered** — only real
|
|
75
174
|
ActiveSupport extensions. For your own core-class patches see the
|
|
76
175
|
`pre_eval:` mechanism ([ADR-17](../../adr/17-monkey-patch-pre-evaluation.md)).
|
|
77
|
-
- **Top ~40 selectors, not exhaustive
|
|
78
|
-
|
|
176
|
+
- **Top ~40 selectors, not exhaustive** — except on `Time`, where the
|
|
177
|
+
closed-core-class argument above makes a sample unsound and the audit
|
|
178
|
+
is exhaustive but for the twelve `:nodoc:` alias-chain artefacts named
|
|
179
|
+
there. Elsewhere ActiveSupport ships hundreds of extensions and this
|
|
180
|
+
covers the head of the real-world distribution.
|
|
79
181
|
|
|
80
182
|
## Plugin internals
|
|
81
183
|
|
|
@@ -42,6 +42,28 @@ arguments.
|
|
|
42
42
|
| `plugin.sidekiq.missing-schedule` | error | `perform_in()` / `perform_at()` called with zero arguments (the schedule is required even when `#perform` takes none) |
|
|
43
43
|
| `plugin.sidekiq.load-error` | warning | worker discovery failed (parse/read error) — once per file |
|
|
44
44
|
|
|
45
|
+
## What it types
|
|
46
|
+
|
|
47
|
+
`perform_async` / `perform_in` / `perform_at` on a **discovered**
|
|
48
|
+
worker return the job id, so the value you assign is a `String`:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
jid = WelcomeEmailWorker.perform_async(123)
|
|
52
|
+
jid.upcase # String — resolved, and checked
|
|
53
|
+
OtherThing.perform_async # untouched: not a discovered worker
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`perform_inline` is not typed — it runs the job in-process and returns
|
|
57
|
+
whatever your `#perform` returns.
|
|
58
|
+
|
|
59
|
+
The type is plain `String`, not `String?`, even though
|
|
60
|
+
`Sidekiq::Client#push` returns nil when a client middleware halts the
|
|
61
|
+
chain. That path needs a middleware in your own app that returns false
|
|
62
|
+
from `#call` — rare, deliberate, and code that already knows to check —
|
|
63
|
+
whereas typing it nullable puts a `call.possible-nil-receiver` error on
|
|
64
|
+
the ordinary `jid = W.perform_async(id); jid.length`. Rigor takes the
|
|
65
|
+
answer that is silent on the common code.
|
|
66
|
+
|
|
45
67
|
## Configuration
|
|
46
68
|
|
|
47
69
|
```yaml
|
|
@@ -5,6 +5,7 @@ require "prism"
|
|
|
5
5
|
require_relative "../../source/node_children"
|
|
6
6
|
require_relative "../../inference/optimistic_origin"
|
|
7
7
|
require_relative "inferred_param_guard"
|
|
8
|
+
require_relative "published_constant_guard"
|
|
8
9
|
|
|
9
10
|
module Rigor
|
|
10
11
|
module Analysis
|
|
@@ -119,6 +120,17 @@ module Rigor
|
|
|
119
120
|
predicate_type = scope.type_of(predicate)
|
|
120
121
|
return unless predicate_type.is_a?(Type::Constant)
|
|
121
122
|
|
|
123
|
+
# Issue #644 — a predicate whose constancy rests on a value constant declared in ANOTHER file is
|
|
124
|
+
# not a logic error the reader's author can see: `if MODE == :production` folds only because the
|
|
125
|
+
# project-wide table published `MODE`, and a configuration constant read in ten files would put
|
|
126
|
+
# this warning in all ten. The value stays published (dispatch and argument typing keep it); only
|
|
127
|
+
# the firing is withheld, which is the direction the carrier discipline allows.
|
|
128
|
+
#
|
|
129
|
+
# LAST of the gates on purpose. It is the only one that can resolve a def and walk its body, and a
|
|
130
|
+
# predicate that does not fold to a `Type::Constant` was never going to fire — so the fold check
|
|
131
|
+
# above is what keeps the hop off every ordinary `if` in a project that publishes anything at all.
|
|
132
|
+
return if PublishedConstantGuard.rooted?(predicate, scope)
|
|
133
|
+
|
|
122
134
|
polarity = predicate_type.value.nil? || predicate_type.value == false ? :falsey : :truthy
|
|
123
135
|
@results << Result.new(node: predicate, polarity: polarity)
|
|
124
136
|
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../../inference/version_guard"
|
|
6
|
+
require_relative "../../source/node_children"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Analysis
|
|
10
|
+
module CheckRules
|
|
11
|
+
# ADR-47 WD5 — drops the diagnostics that land inside the **dead arm of a decidable version guard**
|
|
12
|
+
# (issue #627).
|
|
13
|
+
#
|
|
14
|
+
# {Inference::StatementEvaluator} already skips evaluating that arm, so its writes never join into the
|
|
15
|
+
# post-`if` scope. Skipping the evaluation is not by itself enough to silence the arm, though: the
|
|
16
|
+
# rule walk visits every node of the file whether or not the evaluator typed it, so a call whose
|
|
17
|
+
# receiver is typeable on its own — a constant, a literal — still reports. `mail/lib/mail/yaml.rb`'s
|
|
18
|
+
#
|
|
19
|
+
# ::YAML.safe_load(yaml, permitted_classes) # the Psych < 3.1 positional form
|
|
20
|
+
#
|
|
21
|
+
# is exactly that shape: honest against the Ruby 4 Psych signature, and unreachable on the Ruby the
|
|
22
|
+
# user is checking with. This is the second half of the fix — the arm stops producing diagnostics.
|
|
23
|
+
#
|
|
24
|
+
# The dead arms are found by re-asking {Inference::VersionGuard}, which is a pure function of the AST,
|
|
25
|
+
# so this filter and the evaluator's arm elision cannot disagree about which arm is dead.
|
|
26
|
+
#
|
|
27
|
+
# Applied to the type / flow rules only, and BEFORE `suppression.*` joins the list: a malformed
|
|
28
|
+
# `# rigor:disable` marker inside a dead arm is still a real authoring error — the marker's own
|
|
29
|
+
# well-formedness does not depend on whether the code around it runs.
|
|
30
|
+
module DeadVersionGuardArms
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# @param diagnostics [Array<Rigor::Analysis::Diagnostic>]
|
|
34
|
+
# @param root [Prism::Node]
|
|
35
|
+
# @return [Array<Rigor::Analysis::Diagnostic>]
|
|
36
|
+
def filter(diagnostics, root)
|
|
37
|
+
# The scan is a whole-file walk, so it is paid only when there is something to drop. A file with
|
|
38
|
+
# no diagnostics — the overwhelming majority — never walks.
|
|
39
|
+
return diagnostics if diagnostics.empty?
|
|
40
|
+
|
|
41
|
+
arms = scan(root)
|
|
42
|
+
return diagnostics if arms.empty?
|
|
43
|
+
|
|
44
|
+
diagnostics.reject { |diagnostic| arms.any? { |arm| covers?(arm, diagnostic) } }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param root [Prism::Node]
|
|
48
|
+
# @return [Array<Prism::Location>] the source ranges of every dead version-guard arm
|
|
49
|
+
def scan(root)
|
|
50
|
+
arms = []
|
|
51
|
+
collect(root, arms)
|
|
52
|
+
arms
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def collect(node, arms)
|
|
56
|
+
return unless node.is_a?(Prism::Node)
|
|
57
|
+
|
|
58
|
+
dead = dead_arm(node)
|
|
59
|
+
arms << dead.location if dead
|
|
60
|
+
# Nothing inside a dead arm can produce a surviving diagnostic, so the walk does not descend into
|
|
61
|
+
# it — a nested guard there would only add a range already covered.
|
|
62
|
+
node.rigor_each_child { |child| collect(child, arms) unless dead && child.equal?(dead) }
|
|
63
|
+
end
|
|
64
|
+
private_class_method :collect
|
|
65
|
+
|
|
66
|
+
# The arm that cannot run, or nil when the guard is undecidable (both arms live — the pre-existing
|
|
67
|
+
# behaviour) or the dead arm is absent (`foo if RUBY_VERSION >= "3.1"` has no `else`).
|
|
68
|
+
def dead_arm(node)
|
|
69
|
+
case node
|
|
70
|
+
when Prism::IfNode
|
|
71
|
+
case Inference::VersionGuard.verdict(node.predicate)
|
|
72
|
+
when :truthy then node.subsequent
|
|
73
|
+
when :falsey then node.statements
|
|
74
|
+
end
|
|
75
|
+
when Prism::UnlessNode
|
|
76
|
+
# `unless` runs its body on the FALSEY edge, so the arms are swapped.
|
|
77
|
+
case Inference::VersionGuard.verdict(node.predicate)
|
|
78
|
+
when :truthy then node.statements
|
|
79
|
+
when :falsey then node.else_clause
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
private_class_method :dead_arm
|
|
84
|
+
|
|
85
|
+
# Prism columns are 0-based and `Diagnostic#column` is 1-based; the location's end is exclusive.
|
|
86
|
+
# Compared as `[line, column]` pairs rather than by line alone so a one-line guard
|
|
87
|
+
# (`RUBY_VERSION >= "3.1" ? a(1) : a(1, 2)`) drops only the dead half.
|
|
88
|
+
def covers?(location, diagnostic)
|
|
89
|
+
position = [diagnostic.line, diagnostic.column - 1]
|
|
90
|
+
return false if (position <=> [location.start_line, location.start_column]).negative?
|
|
91
|
+
|
|
92
|
+
(position <=> [location.end_line, location.end_column]).negative?
|
|
93
|
+
end
|
|
94
|
+
private_class_method :covers?
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -69,9 +69,9 @@ module Rigor
|
|
|
69
69
|
|
|
70
70
|
case node
|
|
71
71
|
when Prism::ClassNode, Prism::ModuleNode
|
|
72
|
-
|
|
73
|
-
if
|
|
74
|
-
walk(node.body,
|
|
72
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
73
|
+
if child_prefix
|
|
74
|
+
walk(node.body, child_prefix) if node.body
|
|
75
75
|
return
|
|
76
76
|
end
|
|
77
77
|
when Prism::DefNode
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../../source/constant_path"
|
|
6
|
+
require_relative "../../source/node_children"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Analysis
|
|
10
|
+
module CheckRules
|
|
11
|
+
# Issue #644 — the shared "does this expression's constancy rest on a constant declared in ANOTHER
|
|
12
|
+
# file?" predicate, and the single question the truthiness rule family must ask.
|
|
13
|
+
#
|
|
14
|
+
# The cross-file value-constant table publishes `MODE = :production` project-wide so a reader can
|
|
15
|
+
# dispatch on it, type an argument with it, and see `MODE.upcase` fail. That is the precision the table
|
|
16
|
+
# exists for. What it must NOT do is let a rule *conclude* from the value: `if MODE == :production` in
|
|
17
|
+
# another file folds to `Constant[true]`, and reporting that as `flow.always-truthy-condition` fires on
|
|
18
|
+
# correct code — on the single Ruby idiom whose whole point is a project-wide constant that gets
|
|
19
|
+
# branched on, whose declaration the reader's author never opened. A configuration constant read in ten
|
|
20
|
+
# files would put a warning in all ten.
|
|
21
|
+
#
|
|
22
|
+
# This is [ADR-58](../../../../docs/adr/58-declaration-sourced-ivar-typing.md)'s discipline applied to a
|
|
23
|
+
# different carrier: "a consumer may only ever *withhold* a firing it would otherwise make, so the mark
|
|
24
|
+
# can lose precision but can never manufacture a false positive"
|
|
25
|
+
# (`docs/internal-spec/inference-engine.md` § "Carrier and discipline"). The carrier is deliberately NOT
|
|
26
|
+
# the ADR-58 `Set[[kind, name]]`: that set is flow state, keyed on a *binding* that rebinding drops, and
|
|
27
|
+
# it is already shared by two marks with opposite join policies (ADR-67 WD6b's `:inferred_param` unions
|
|
28
|
+
# where ADR-58's kinds intersect). "Which constants did the project publish, and which does this file
|
|
29
|
+
# declare itself" is ambient, never varies along a flow edge, and so belongs in the
|
|
30
|
+
# `Scope::DiscoveryIndex` — which is exactly ADR-53's membership criterion.
|
|
31
|
+
#
|
|
32
|
+
# `rooted?` is purely syntactic, in the shape of {InferredParamGuard}: it walks a predicate expression
|
|
33
|
+
# down to the roots its constancy could rest on and reports whether any is a published foreign
|
|
34
|
+
# constant. It covers
|
|
35
|
+
#
|
|
36
|
+
# - the bare predicate: `if PAGE_SIZE`, `unless AppConfig::LOG_LEVEL`
|
|
37
|
+
# - a comparison or any method chain on one: `MODE == :production`, `PAGE_SIZE > 100`, `VERSION >= 3`
|
|
38
|
+
# - a constant in ARGUMENT position: `%i[development test].include?(ENV_NAME)` — unlike
|
|
39
|
+
# {InferredParamGuard}, arguments are walked, because a predicate's fold rests on its arguments just
|
|
40
|
+
# as much as on its receiver
|
|
41
|
+
# - `&&` / `||` / parentheses / `rescue` composition, and `!`, which Prism spells as a `CallNode`
|
|
42
|
+
# - ONE interprocedural hop into a project predicate method whose body reads such a constant, whatever
|
|
43
|
+
# spells its owner: `unless production?` (implicit self), `self.prod?`, `AppConfig.production?` (the
|
|
44
|
+
# idiomatic shape — a configuration module exposing predicates), a lexically relative
|
|
45
|
+
# `Inner.production?`, and a call chain `KlassConfig.new.production?`. The owner comes from the
|
|
46
|
+
# receiver's inferred TYPE, so every spelling that names the same module lands on the same table. The
|
|
47
|
+
# hop is bounded (one level, a node budget) and deliberately coarse: ANY published foreign constant
|
|
48
|
+
# anywhere in the callee's body declines, rather than a proof that the return value derives from it.
|
|
49
|
+
# Over-declining withholds a warning; under-declining would emit one. ADR-58's *Non-transitivity*
|
|
50
|
+
# passage is the precedent for stopping at one hop rather than building a provenance channel through
|
|
51
|
+
# the return memo.
|
|
52
|
+
#
|
|
53
|
+
# Deliberately NOT covered, and a separate change: a value copied into a local (`m = MODE; m == :x`),
|
|
54
|
+
# into an ivar (`@mode = MODE` in `initialize`), or through a same-file constant alias
|
|
55
|
+
# (`MODE2 = AppConfig::MODE`). Each needs flow provenance — ADR-58's `r = @x` stamping is the shape —
|
|
56
|
+
# rather than a syntactic root walk, and approximating it here is exactly the ad-hoc widening ADR-58's
|
|
57
|
+
# *Non-transitivity* passage forbids. Two hops (`live? -> prod? -> MODE`) also stay uncovered.
|
|
58
|
+
module PublishedConstantGuard
|
|
59
|
+
# A defensive depth cap against a pathological chain (the walk is otherwise linear in chain length).
|
|
60
|
+
MAX_DEPTH = 64
|
|
61
|
+
|
|
62
|
+
# A node cap on the one-hop callee-body scan, so a predicate calling a very large method cannot make
|
|
63
|
+
# rule collection quadratic. Exhausting it answers false — the firing direction, and the same
|
|
64
|
+
# conservative default the collector's other gates take when they cannot decide.
|
|
65
|
+
BODY_SCAN_BUDGET = 2000
|
|
66
|
+
|
|
67
|
+
module_function
|
|
68
|
+
|
|
69
|
+
# @return [Boolean] true when the rule MUST withhold its firing.
|
|
70
|
+
def rooted?(node, scope, depth = 0)
|
|
71
|
+
return false if node.nil? || depth > MAX_DEPTH || scope.nil?
|
|
72
|
+
|
|
73
|
+
case node
|
|
74
|
+
when Prism::ConstantReadNode then scope.published_constant?(node.name.to_s)
|
|
75
|
+
when Prism::ConstantPathNode then published_path?(node, scope)
|
|
76
|
+
when Prism::CallNode then rooted_call?(node, scope, depth)
|
|
77
|
+
else rooted_through_composition?(node, scope, depth)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# `&&` / `||` / `rescue` combine two operands and a parenthesised or multi-statement body yields its
|
|
82
|
+
# last: the produced value is constant only if a rooted operand made it so, so declining on either
|
|
83
|
+
# side is the withholding direction.
|
|
84
|
+
def rooted_through_composition?(node, scope, depth)
|
|
85
|
+
case node
|
|
86
|
+
when Prism::AndNode, Prism::OrNode
|
|
87
|
+
rooted?(node.left, scope, depth + 1) || rooted?(node.right, scope, depth + 1)
|
|
88
|
+
when Prism::RescueModifierNode
|
|
89
|
+
rooted?(node.expression, scope, depth + 1) || rooted?(node.rescue_expression, scope, depth + 1)
|
|
90
|
+
when Prism::ParenthesesNode then rooted?(last_statement(node.body), scope, depth + 1)
|
|
91
|
+
when Prism::StatementsNode then rooted?(node.body.last, scope, depth + 1)
|
|
92
|
+
else false
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A call is rooted when its receiver is, when any argument is, or when the project method it names
|
|
97
|
+
# reads such a constant (the one interprocedural hop).
|
|
98
|
+
def rooted_call?(node, scope, depth)
|
|
99
|
+
return true if rooted?(node.receiver, scope, depth + 1)
|
|
100
|
+
return true if Array(node.arguments&.arguments).any? { |arg| rooted?(arg, scope, depth + 1) }
|
|
101
|
+
|
|
102
|
+
callee_reads_published_constant?(node, scope)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The reference AS WRITTEN (`AppConfig::MODE`), so `Scope#published_constant?` can match its local
|
|
106
|
+
# exemption exactly rather than on the last segment. A path that renders no qualified name falls back
|
|
107
|
+
# to its own last segment, which is all there is to ask about.
|
|
108
|
+
def published_path?(node, scope)
|
|
109
|
+
rendered = Source::ConstantPath.qualified_name_or_nil(node) || node.name&.to_s
|
|
110
|
+
!rendered.nil? && scope.published_constant?(rendered)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The one hop. Resolves the callee through the ordinary `Scope` accessors (so the ADR-46 recorder sees
|
|
114
|
+
# the read and the caller gains an edge to the callee — the conservative direction) and scans its
|
|
115
|
+
# body. Fails soft to false: a resolution this cannot make is not a reason to withhold.
|
|
116
|
+
def callee_reads_published_constant?(node, scope)
|
|
117
|
+
return false if scope.published_constant_names.empty?
|
|
118
|
+
|
|
119
|
+
def_node = resolve_callee(node, scope)
|
|
120
|
+
return false if def_node.nil?
|
|
121
|
+
|
|
122
|
+
body_reads_published_constant?(def_node.body, scope)
|
|
123
|
+
rescue StandardError
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# `foo` / `self.foo` resolve against the enclosing self. Every other receiver resolves through its
|
|
128
|
+
# inferred TYPE, never through the name it was spelled with: `Inner.production?` inside `module Outer`
|
|
129
|
+
# is `Outer::Inner`, and keying the table on the bare `Inner` both misses that (a new warning on
|
|
130
|
+
# ordinary code) and, where a top-level `Inner` also exists, reads the WRONG module's methods. That is
|
|
131
|
+
# the defect family #635 fixed in `Narrowing`, so the engine's own resolver answers it rather than a
|
|
132
|
+
# walk reimplemented here. Going through the type also covers a call-chain receiver for free —
|
|
133
|
+
# `KlassConfig.new.production?` types `Nominal[KlassConfig]`, whose instance table is the one to read.
|
|
134
|
+
def resolve_callee(node, scope)
|
|
135
|
+
receiver = node.receiver
|
|
136
|
+
return resolve_self_call(node.name, scope) if receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
137
|
+
|
|
138
|
+
resolve_typed_receiver_call(receiver, node.name, scope)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A `Singleton[X]` receiver names the singleton table (`AppConfig.production?`, a module's own
|
|
142
|
+
# predicates); a `Nominal[X]` receiver names the instance one. Anything else — `Dynamic`, a constant
|
|
143
|
+
# that resolves to no project class — is not a hop this walk can make.
|
|
144
|
+
def resolve_typed_receiver_call(receiver, method_name, scope)
|
|
145
|
+
receiver_type = scope.type_of(receiver)
|
|
146
|
+
case receiver_type
|
|
147
|
+
when Type::Singleton then scope.singleton_def_for(receiver_type.class_name, method_name)
|
|
148
|
+
when Type::Nominal then scope.user_def_for(receiver_type.class_name, method_name)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# A `Singleton[X]` self (a `def self.…` body) names the singleton table; a `Nominal[X]` self names
|
|
153
|
+
# the instance one; no class at all is the top-level pseudo-class.
|
|
154
|
+
def resolve_self_call(method_name, scope)
|
|
155
|
+
self_type = scope.self_type
|
|
156
|
+
owner = self_type.respond_to?(:class_name) ? self_type.class_name : nil
|
|
157
|
+
return scope.top_level_def_for(method_name) if owner.nil?
|
|
158
|
+
|
|
159
|
+
if self_type.is_a?(Type::Singleton)
|
|
160
|
+
scope.singleton_def_for(owner, method_name)
|
|
161
|
+
else
|
|
162
|
+
scope.user_def_for(owner, method_name)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# An explicit worklist rather than recursion, so the node budget is one loop-carried local instead of
|
|
167
|
+
# a shared mutable cell (the engine value-pins a one-element Array counter and then self-flags the
|
|
168
|
+
# guard's own `negative?` as an always-falsey condition — this rule's own medicine).
|
|
169
|
+
def body_reads_published_constant?(root, scope)
|
|
170
|
+
stack = [root]
|
|
171
|
+
budget = BODY_SCAN_BUDGET
|
|
172
|
+
until stack.empty?
|
|
173
|
+
node = stack.pop
|
|
174
|
+
next unless node.is_a?(Prism::Node)
|
|
175
|
+
|
|
176
|
+
budget -= 1
|
|
177
|
+
return false if budget.negative?
|
|
178
|
+
return true if published_constant_node?(node, scope)
|
|
179
|
+
|
|
180
|
+
node.rigor_each_child { |child| stack << child }
|
|
181
|
+
end
|
|
182
|
+
false
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def published_constant_node?(node, scope)
|
|
186
|
+
case node
|
|
187
|
+
when Prism::ConstantReadNode then scope.published_constant?(node.name.to_s)
|
|
188
|
+
when Prism::ConstantPathNode then published_path?(node, scope)
|
|
189
|
+
else false
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def last_statement(body)
|
|
194
|
+
body.is_a?(Prism::StatementsNode) ? body.body.last : body
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -170,8 +170,7 @@ module Rigor
|
|
|
170
170
|
def extend_prefix(node, prefix)
|
|
171
171
|
return prefix unless CLASS_OR_MODULE_NODE_CLASSES.any? { |klass| node.is_a?(klass) }
|
|
172
172
|
|
|
173
|
-
|
|
174
|
-
name ? prefix + [name] : prefix
|
|
173
|
+
Source::ConstantPath.declaration_prefix(prefix, node.constant_path) || prefix
|
|
175
174
|
end
|
|
176
175
|
end
|
|
177
176
|
end
|
|
@@ -44,12 +44,12 @@ module Rigor
|
|
|
44
44
|
case node
|
|
45
45
|
when Prism::ModuleNode
|
|
46
46
|
name = Source::ConstantPath.qualified_name_or_nil(node.constant_path)
|
|
47
|
-
child_prefix =
|
|
47
|
+
child_prefix = Source::ConstantPath.declaration_prefix(prefix, node.constant_path) || prefix
|
|
48
48
|
names << child_prefix.join("::") if name
|
|
49
49
|
walk(node.body, child_prefix, names) if node.body
|
|
50
50
|
when Prism::ClassNode
|
|
51
51
|
name = Source::ConstantPath.qualified_name_or_nil(node.constant_path)
|
|
52
|
-
child_prefix =
|
|
52
|
+
child_prefix = Source::ConstantPath.declaration_prefix(prefix, node.constant_path) || prefix
|
|
53
53
|
names << child_prefix.join("::") if name && class_surface_open?(node)
|
|
54
54
|
walk(node.body, child_prefix, names) if node.body
|
|
55
55
|
else
|