rigortype 0.3.2 → 0.3.4
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/array.yml +11 -24
- data/data/builtins/ruby_core/enumerable.yml +4 -8
- data/data/builtins/ruby_core/exception.yml +6 -9
- data/data/builtins/ruby_core/hash.yml +13 -20
- data/data/builtins/ruby_core/io.yml +6 -9
- data/data/builtins/ruby_core/numeric.yml +2 -3
- data/data/builtins/ruby_core/proc.yml +4 -8
- data/data/builtins/ruby_core/re.yml +6 -9
- data/data/builtins/ruby_core/set.yml +2 -3
- data/data/builtins/ruby_core/string.yml +4 -7
- data/data/builtins/ruby_core/struct.yml +0 -6
- data/data/builtins/ruby_core/time.yml +3 -6
- data/data/effects/core.yml +843 -0
- data/data/effects/registry.yml +67 -0
- data/docs/handbook/07-rbs-and-extended.md +106 -5
- data/docs/manual/02-cli-reference.md +311 -1
- data/docs/manual/03-configuration.md +84 -1
- data/docs/manual/04-diagnostics.md +14 -2
- data/docs/manual/16-rbs-extended-annotations.md +77 -0
- data/docs/manual/18-removing-dead-code.md +326 -0
- data/docs/manual/README.md +5 -0
- data/docs/manual/plugins/rigor-actionmailer.md +5 -0
- data/docs/manual/plugins/rigor-activejob.md +47 -0
- data/docs/manual/plugins/rigor-factorybot.md +16 -0
- data/docs/manual/plugins/rigor-pundit.md +26 -0
- data/docs/manual/plugins/rigor-rails-routes.md +41 -2
- data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
- data/docs/manual/plugins/rigor-rspec.md +8 -0
- data/docs/manual/plugins/rigor-sidekiq.md +54 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
- data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
- data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +62 -22
- data/lib/rigor/analysis/incremental_session.rb +140 -6
- data/lib/rigor/analysis/reachability/graph.rb +244 -0
- data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
- data/lib/rigor/analysis/reachability/project_files.rb +58 -0
- data/lib/rigor/analysis/reachability/scan.rb +241 -0
- data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
- data/lib/rigor/analysis/rule_catalog.rb +163 -1
- data/lib/rigor/analysis/run_cache_key.rb +16 -0
- data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
- data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
- data/lib/rigor/analysis/runner.rb +420 -10
- data/lib/rigor/analysis/severity_stamp.rb +2 -1
- data/lib/rigor/analysis/worker_session.rb +58 -2
- data/lib/rigor/bleeding_edge.rb +22 -0
- data/lib/rigor/cache/incremental_snapshot.rb +24 -5
- data/lib/rigor/cli/check_command.rb +13 -1
- data/lib/rigor/cli/check_runner_factory.rb +4 -1
- data/lib/rigor/cli/effects_command.rb +131 -0
- data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
- data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
- data/lib/rigor/cli/effects_renderer.rb +54 -0
- data/lib/rigor/cli/effects_report.rb +53 -0
- data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
- data/lib/rigor/cli/unused_command.rb +288 -0
- data/lib/rigor/cli.rb +16 -0
- data/lib/rigor/configuration/severity_profile.rb +23 -0
- data/lib/rigor/configuration.rb +296 -14
- data/lib/rigor/effects/attribution.rb +76 -0
- data/lib/rigor/effects/catalog.rb +275 -0
- data/lib/rigor/effects/collector.rb +182 -0
- data/lib/rigor/effects/config_envelopes.rb +185 -0
- data/lib/rigor/effects/discharge.rb +69 -0
- data/lib/rigor/effects/effect_table.rb +92 -0
- data/lib/rigor/effects/entry_points.rb +93 -0
- data/lib/rigor/effects/envelope.rb +86 -0
- data/lib/rigor/effects/envelope_check.rb +172 -0
- data/lib/rigor/effects/envelope_index.rb +157 -0
- data/lib/rigor/effects/file_collection.rb +160 -0
- data/lib/rigor/effects/framework_units.rb +167 -0
- data/lib/rigor/effects/identity.rb +104 -0
- data/lib/rigor/effects/label.rb +77 -0
- data/lib/rigor/effects/label_intent.rb +73 -0
- data/lib/rigor/effects/label_set.rb +136 -0
- data/lib/rigor/effects/liskov_check.rb +167 -0
- data/lib/rigor/effects/local_ownership.rb +132 -0
- data/lib/rigor/effects/method_key.rb +40 -0
- data/lib/rigor/effects/mutation_classifier.rb +92 -0
- data/lib/rigor/effects/narrowing.rb +202 -0
- data/lib/rigor/effects/origin.rb +66 -0
- data/lib/rigor/effects/path_finder.rb +89 -0
- data/lib/rigor/effects/plugin_facts.rb +335 -0
- data/lib/rigor/effects/propagator.rb +275 -0
- data/lib/rigor/effects/registry.rb +193 -0
- data/lib/rigor/effects/scanner.rb +290 -0
- data/lib/rigor/effects/signature_sources.rb +74 -0
- data/lib/rigor/effects/snapshot.rb +380 -0
- data/lib/rigor/effects/snapshot_diff.rb +265 -0
- data/lib/rigor/effects/summary.rb +154 -0
- data/lib/rigor/effects/taint_cause.rb +38 -0
- data/lib/rigor/effects/unit_scan.rb +572 -0
- data/lib/rigor/effects/unknown_label_check.rb +86 -0
- data/lib/rigor/effects/unknown_label_report.rb +59 -0
- data/lib/rigor/effects.rb +47 -0
- data/lib/rigor/environment/rbs_loader.rb +34 -0
- data/lib/rigor/environment.rb +8 -1
- data/lib/rigor/flow_contribution/element.rb +1 -0
- data/lib/rigor/flow_contribution/merge_result.rb +5 -3
- data/lib/rigor/flow_contribution/merger.rb +16 -1
- data/lib/rigor/flow_contribution.rb +20 -4
- data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
- data/lib/rigor/inference/expression_typer.rb +74 -17
- data/lib/rigor/inference/fork_map.rb +45 -11
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
- data/lib/rigor/inference/method_dispatcher.rb +41 -10
- data/lib/rigor/inference/optimistic_origin.rb +68 -3
- data/lib/rigor/inference/pre_eval_constants.rb +189 -0
- data/lib/rigor/inference/scope_indexer.rb +134 -31
- data/lib/rigor/inference/singleton_object_constant.rb +63 -0
- data/lib/rigor/inference/statement_evaluator.rb +25 -12
- data/lib/rigor/plugin/base.rb +24 -0
- data/lib/rigor/plugin/effect_attribution.rb +208 -0
- data/lib/rigor/plugin/effect_edge.rb +101 -0
- data/lib/rigor/plugin/effect_entry_points.rb +51 -0
- data/lib/rigor/plugin/first_party.rb +57 -0
- data/lib/rigor/plugin/isolation.rb +4 -1
- data/lib/rigor/plugin/manifest.rb +103 -3
- data/lib/rigor/plugin/node_rule_walk.rb +6 -0
- data/lib/rigor/plugin/registry.rb +48 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
- data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
- data/lib/rigor/rbs_extended.rb +168 -0
- data/lib/rigor/reflection.rb +131 -30
- data/lib/rigor/scope.rb +62 -2
- data/lib/rigor/source/node_walker.rb +12 -0
- data/lib/rigor/type/anonymous_class_name.rb +40 -0
- data/lib/rigor/type/nominal.rb +5 -2
- data/lib/rigor/type/singleton.rb +4 -1
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
- data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
- data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
- data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
- data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
- data/sig/rigor/analysis/fact_store.rbs +1 -0
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor/rbs_extended.rbs +6 -0
- data/sig/rigor/scope.rbs +4 -0
- data/sig/rigor.rbs +21 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- metadata +83 -1
|
@@ -67,6 +67,14 @@ to enable project-wide alongside non-spec files.
|
|
|
67
67
|
not flagged.
|
|
68
68
|
- Constant validation (`RSpec.describe SomeClass`) is the
|
|
69
69
|
engine's job, not this plugin's.
|
|
70
|
+
- **No roots for [`rigor unused`](../02-cli-reference.md#rigor-unused).**
|
|
71
|
+
`RSpec.describe User` is already recorded as a reference, stamped
|
|
72
|
+
with the `test` role because it came from a spec. Publishing spec
|
|
73
|
+
references as roots would strip that role, promote every
|
|
74
|
+
spec-referenced class to production-reachable, and delete the
|
|
75
|
+
report's **Reachable only from test code** section — the row that
|
|
76
|
+
tells you a class is dead production code with a live test. The
|
|
77
|
+
ordinary handling is the correct one.
|
|
70
78
|
|
|
71
79
|
## Related plugins
|
|
72
80
|
|
|
@@ -50,11 +50,61 @@ plugins:
|
|
|
50
50
|
config:
|
|
51
51
|
worker_search_paths: ["app/workers", "app/sidekiq"] # default
|
|
52
52
|
worker_marker_modules: ["Sidekiq::Job", "Sidekiq::Worker"] # default
|
|
53
|
+
schedule_paths: # default
|
|
54
|
+
- "config/schedule.yml"
|
|
55
|
+
- "config/sidekiq.yml"
|
|
53
56
|
```
|
|
54
57
|
|
|
55
58
|
The default `worker_marker_modules` covers both modern Sidekiq
|
|
56
59
|
(`Sidekiq::Job`, since 6.3) and the legacy `Sidekiq::Worker`.
|
|
57
60
|
|
|
61
|
+
`schedule_paths` are the schedule *files* — not directories — behind
|
|
62
|
+
the reachability roots below. The defaults are where the two schedule
|
|
63
|
+
layouts conventionally live; list your own path if you keep the
|
|
64
|
+
schedule elsewhere.
|
|
65
|
+
|
|
66
|
+
## Worker roots for `rigor unused`
|
|
67
|
+
|
|
68
|
+
A cron-scheduled worker is enqueued **by name from YAML**, so
|
|
69
|
+
`NightlyReportWorker` can appear nowhere in your code. Without help,
|
|
70
|
+
[`rigor unused`](../02-cli-reference.md#rigor-unused) reports a job
|
|
71
|
+
that runs every night as possibly dead. This plugin supplies the
|
|
72
|
+
workers your schedule names, so they drop out of the candidate list:
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
# config/schedule.yml (sidekiq-cron) config/sidekiq.yml (sidekiq-scheduler)
|
|
76
|
+
nightly_report: :scheduler:
|
|
77
|
+
cron: "0 3 * * *" :schedule:
|
|
78
|
+
class: "NightlyReportWorker" nightly_report:
|
|
79
|
+
every: "1h"
|
|
80
|
+
class: "NightlyReportWorker"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
It reads the `class:` key and nothing else. In particular a **queue
|
|
84
|
+
name is not a class name**: the `:queues:` list in `sidekiq.yml`
|
|
85
|
+
supplies no roots, because inflecting `report_worker` into
|
|
86
|
+
`ReportWorker` would root a worker on a naming coincidence. And a
|
|
87
|
+
`class:` naming a worker the plugin never discovered is dropped rather
|
|
88
|
+
than published, so a typo costs you a root instead of quietly hiding a
|
|
89
|
+
dead worker.
|
|
90
|
+
|
|
91
|
+
`MyWorker.perform_async` still supplies nothing — it is an ordinary
|
|
92
|
+
constant reference the report already sees. Neither does the mere
|
|
93
|
+
existence of a file under `app/workers`: a worker nothing enqueues
|
|
94
|
+
stays in the report, which is the answer you wanted.
|
|
95
|
+
|
|
96
|
+
The schedule is read with `YAML.safe_load`; nothing boots.
|
|
97
|
+
A missing, unreadable or malformed file is skipped without affecting
|
|
98
|
+
the rest of the run.
|
|
99
|
+
|
|
100
|
+
If your workers include a project concern (`include ApplicationWorker`)
|
|
101
|
+
rather than `Sidekiq::Job` directly, add that concern to
|
|
102
|
+
`worker_marker_modules` — otherwise the plugin discovers no workers,
|
|
103
|
+
every scheduled name is dropped, and you get no roots at all. GitLab's
|
|
104
|
+
`config/schedule.yml` names 111 workers, all of them concern-based:
|
|
105
|
+
with the default markers that is 0 roots, and with `ApplicationWorker`
|
|
106
|
+
added it is 100.
|
|
107
|
+
|
|
58
108
|
## Limitations
|
|
59
109
|
|
|
60
110
|
- **Direct `include` only.** A worker that mixes in a custom concern
|
|
@@ -68,6 +118,10 @@ The default `worker_marker_modules` covers both modern Sidekiq
|
|
|
68
118
|
`perform_at` is consumed as the schedule regardless of its type.
|
|
69
119
|
- **Chained `set(...)`** (`Worker.set(queue: "low").perform_async(...)`)
|
|
70
120
|
is validated as a normal call; `set`'s own options are not checked.
|
|
121
|
+
- **Schedule roots are read from `class:` only.** `sidekiq-cron`'s
|
|
122
|
+
alternative `klass:` spelling, and a schedule built in Ruby with
|
|
123
|
+
`Sidekiq::Cron::Job.load_from_hash!`, supply no roots — the worker
|
|
124
|
+
stays a `rigor unused` candidate rather than being guessed at.
|
|
71
125
|
|
|
72
126
|
## Plugin internals
|
|
73
127
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
5
|
require_relative "../../source/node_children"
|
|
6
|
+
require_relative "../../inference/optimistic_origin"
|
|
6
7
|
require_relative "inferred_param_guard"
|
|
7
8
|
|
|
8
9
|
module Rigor
|
|
@@ -108,6 +109,13 @@ module Rigor
|
|
|
108
109
|
# the precision-additive contract.
|
|
109
110
|
return if InferredParamGuard.rooted?(predicate, scope)
|
|
110
111
|
|
|
112
|
+
# Issue #313 — a predicate whose constancy rests on an optimistically nil-free carrier is a bet, not
|
|
113
|
+
# proof: `MAP[key]` omits `nil` because pessimising the defaulted-Hash idiom costs more false
|
|
114
|
+
# positives than the miss it would model, so `MAP[key].nil?` folding to `false` says nothing about
|
|
115
|
+
# whether the key was there. The spec forbids this rule concluding from such a value; the derivation
|
|
116
|
+
# is what carries the mark across `.nil?`, `!` and `&&` / `||` to the predicate the rule reads.
|
|
117
|
+
return unless Inference::OptimisticOrigin.resolve(predicate, scope).nil?
|
|
118
|
+
|
|
111
119
|
predicate_type = scope.type_of(predicate)
|
|
112
120
|
return unless predicate_type.is_a?(Type::Constant)
|
|
113
121
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Analysis
|
|
7
|
+
module CheckRules
|
|
8
|
+
# ADR-58 WD1 — the shared "is this value's optionality declaration-sourced?" predicate.
|
|
9
|
+
#
|
|
10
|
+
# A `nil` whose only provenance is a declaration (the class-ivar index seed of a ctor `@x = nil`, a
|
|
11
|
+
# non-definitely-assigned ivar read) is real type information but not diagnostic fuel: the working
|
|
12
|
+
# program's cross-method invariant is assumed per the robustness principle ([ADR-5]). Every negative rule
|
|
13
|
+
# that would fire *because of* such a nil must therefore consult the same provenance question, and get the
|
|
14
|
+
# same answer.
|
|
15
|
+
#
|
|
16
|
+
# Before issue #324 each rule spelled the lookup itself, and they drifted: `call.possible-nil-receiver`
|
|
17
|
+
# read the `:local` mark (so `r = @right; r.key` was excused — ADR-58's own motivating shape), while both
|
|
18
|
+
# `call.argument-type-mismatch` gates opened by requiring a literal `Prism::InstanceVariableReadNode`, so
|
|
19
|
+
# `c = @count; sink.take_int(c)` fired on the same value with the same provenance. This module is that one
|
|
20
|
+
# question, asked in one place.
|
|
21
|
+
#
|
|
22
|
+
# **The mark is deliberately NOT transitive.** `Scope#with_declaration_sourced_local`
|
|
23
|
+
# ({Inference::StatementEvaluator#eval_local_write}) stamps `:local` only when the RHS is a *pure read of a
|
|
24
|
+
# currently declaration-sourced ivar*, so a second hop (`c = @count; d = c`) and an `||=` rewrite land on
|
|
25
|
+
# the plain `with_local` path and carry no mark.
|
|
26
|
+
#
|
|
27
|
+
# A branch join is NOT one of those cases, contrary to what this comment claimed when it landed: both
|
|
28
|
+
# arms of `if c then r = @count else r = @count end` stamp `(:local, :r)`, and `join_declaration_sourced`
|
|
29
|
+
# INTERSECTS, so the mark survives. Only an *asymmetric* join drops it, and by that intersection rather
|
|
30
|
+
# than by reaching `with_local`. The normative statement — establishment, drop, join and the full list of
|
|
31
|
+
# unmarked shapes, each verified against the implementation — is
|
|
32
|
+
# `docs/internal-spec/inference-engine.md` § "Declaration-sourced provenance mark (ADR-58)"; keep this
|
|
33
|
+
# comment subordinate to it.
|
|
34
|
+
#
|
|
35
|
+
# So `marked?` matches exactly the two node shapes the scope actually models and invents no propagation
|
|
36
|
+
# of its own; anything flow-live keeps firing.
|
|
37
|
+
module DeclarationSourcedGuard
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# True when `node` is a direct read of a binding whose optionality is purely declaration-sourced. Any
|
|
41
|
+
# flow-live touch (a method-local `@x = nil` write, a failed-guard narrowing, a rebinding local write)
|
|
42
|
+
# drops the mark upstream, so this returns false there and the caller fires exactly as before.
|
|
43
|
+
def marked?(node, scope)
|
|
44
|
+
case node
|
|
45
|
+
when Prism::InstanceVariableReadNode then scope.declaration_sourced?(:ivar, node.name)
|
|
46
|
+
when Prism::LocalVariableReadNode then scope.declaration_sourced?(:local, node.name)
|
|
47
|
+
else false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -44,6 +44,28 @@ module Rigor
|
|
|
44
44
|
# return, used in value context. Authored `:warning`, resolved `:off` by every profile and promoted to
|
|
45
45
|
# `:warning` only by the `use-of-void-value` bleeding-edge feature.
|
|
46
46
|
RULE_VALUE_USE_VOID = "static.value-use.void"
|
|
47
|
+
# ADR-103 WD8 / #383 — the first `effect.*` id: a method whose PROVEN effect labels are not
|
|
48
|
+
# subsumed by the envelope its author declared (`%a{pure}` / `%a{rigor:v1:effect ...}`). Opt-in
|
|
49
|
+
# twice over — the `effects:` block enables collection, and the envelope is the author's own
|
|
50
|
+
# directive — so it is never unsolicited.
|
|
51
|
+
RULE_EFFECT_ENVELOPE_EXCEEDED = "effect.envelope-exceeded"
|
|
52
|
+
# ADR-103 WD1 / #384 — the paired vocabulary diagnostic. An unknown label degrades the whole tag
|
|
53
|
+
# to ⊤, which is silent by construction; this is what keeps that fail-open reading honest. It
|
|
54
|
+
# fires only where label intent is evident (`Effects::LabelIntent`'s four signals), so a project
|
|
55
|
+
# opening its own vocabulary is never nagged, and it is gated by the same `effects.check` switch
|
|
56
|
+
# as its sibling: opting into envelope enforcement is exactly what turns on the diagnostic that
|
|
57
|
+
# says an envelope stopped enforcing.
|
|
58
|
+
RULE_EFFECT_UNKNOWN_LABEL = "effect.unknown-label"
|
|
59
|
+
# ADR-103 WD1 / WD14 / #386 — the inherited-bound reading. An override performs, or itself
|
|
60
|
+
# declares, an effect the envelope written on the method it overrides does not admit. Implementations
|
|
61
|
+
# may be purer than the bound they inherit, never less pure; both-sides-authored in the ADR-35 sense,
|
|
62
|
+
# so nothing fires unless someone wrote an envelope on the ancestor.
|
|
63
|
+
RULE_EFFECT_LISKOV_WIDENED = "effect.liskov-widened"
|
|
64
|
+
# ADR-103 WD13 commitment 1 / #384 — the residual. A project whose RBS carries `%a{pure}` /
|
|
65
|
+
# `%a{rigor:v1:effect …}` but no `effects:` block gets ONE `:info` per run saying so. An
|
|
66
|
+
# annotation must never turn collection on by itself (that would be a project-wide cost cliff
|
|
67
|
+
# nobody asked for), and it must equally never be silently inert.
|
|
68
|
+
RULE_EFFECT_ANNOTATIONS_UNCHECKED = "effect.annotations-unchecked"
|
|
47
69
|
|
|
48
70
|
ALL_RULES = [
|
|
49
71
|
RULE_UNDEFINED_METHOD,
|
|
@@ -72,7 +94,11 @@ module Rigor
|
|
|
72
94
|
RULE_SUPPRESSION_UNKNOWN_RULE,
|
|
73
95
|
RULE_SUPPRESSION_EMPTY,
|
|
74
96
|
RULE_SUPPRESSION_UNKNOWN_MARKER,
|
|
75
|
-
RULE_VALUE_USE_VOID
|
|
97
|
+
RULE_VALUE_USE_VOID,
|
|
98
|
+
RULE_EFFECT_ENVELOPE_EXCEEDED,
|
|
99
|
+
RULE_EFFECT_LISKOV_WIDENED,
|
|
100
|
+
RULE_EFFECT_UNKNOWN_LABEL,
|
|
101
|
+
RULE_EFFECT_ANNOTATIONS_UNCHECKED
|
|
76
102
|
].freeze
|
|
77
103
|
|
|
78
104
|
# Backward-compat alias table (ADR-8 § "Backward compatibility"). Existing user code with
|
|
@@ -102,7 +128,7 @@ module Rigor
|
|
|
102
128
|
|
|
103
129
|
# Family wildcard — a `<family>` token in a suppression comment or `disable:` list disables every rule
|
|
104
130
|
# whose canonical id starts with `<family>.`. Per ADR-8 § "1".
|
|
105
|
-
RULE_FAMILIES = %w[call flow assert dump def suppression static].freeze
|
|
131
|
+
RULE_FAMILIES = %w[call flow assert dump def suppression static effect].freeze
|
|
106
132
|
|
|
107
133
|
# Families of diagnostics the engine emits OUTSIDE the CheckRules catalogue (aggregator-level and
|
|
108
134
|
# reporter-level diagnostics such as `rbs_extended.unsatisfied-conformance`,
|
|
@@ -145,6 +145,13 @@ module Rigor
|
|
|
145
145
|
return unless node.is_a?(Prism::Node)
|
|
146
146
|
|
|
147
147
|
dispatch(node, hooks, context)
|
|
148
|
+
# Issue #318 — `defined?`'s operand is never evaluated (the runtime inspects it statically), so
|
|
149
|
+
# nothing under a `DefinedNode` is reachable, evaluated code. Dispatching the DefinedNode itself
|
|
150
|
+
# is fine (a collector may care that it exists); descending into `#value` would feed collectors
|
|
151
|
+
# source that can never execute, e.g. `undefined_method_diagnostic` flagging a call inside
|
|
152
|
+
# `defined? @x && @x.method_call`.
|
|
153
|
+
return if node.is_a?(Prism::DefinedNode)
|
|
154
|
+
|
|
148
155
|
child_context = descend(node, context)
|
|
149
156
|
node.rigor_each_child { |child| walk(child, hooks, child_context) }
|
|
150
157
|
end
|
|
@@ -5,11 +5,13 @@ require "prism"
|
|
|
5
5
|
require_relative "../reflection"
|
|
6
6
|
require_relative "../source/node_walker"
|
|
7
7
|
require_relative "../source/constant_path"
|
|
8
|
+
require_relative "../inference/singleton_object_constant"
|
|
8
9
|
require_relative "../type"
|
|
9
10
|
require_relative "diagnostic"
|
|
10
11
|
require_relative "dependency_recorder"
|
|
11
12
|
require_relative "check_rules/rule_ids"
|
|
12
13
|
require_relative "check_rules/inferred_param_guard"
|
|
14
|
+
require_relative "check_rules/declaration_sourced_guard"
|
|
13
15
|
require_relative "check_rules/rule_walk"
|
|
14
16
|
require_relative "check_rules/always_truthy_condition_collector"
|
|
15
17
|
require_relative "check_rules/unreachable_clause_collector"
|
|
@@ -424,6 +426,12 @@ module Rigor
|
|
|
424
426
|
# only. `# rigor:disable all` on a line suppresses
|
|
425
427
|
# every rule on that line.
|
|
426
428
|
#
|
|
429
|
+
# Both in-source forms are recognised only when the
|
|
430
|
+
# marker is the FIRST thing in the comment, so a
|
|
431
|
+
# directive quoted inside ordinary prose — as the bullets
|
|
432
|
+
# above quote it — is not a directive. See the pattern
|
|
433
|
+
# constants below.
|
|
434
|
+
#
|
|
427
435
|
# Diagnostics with `rule == nil` (parse errors, path
|
|
428
436
|
# errors, internal analyzer errors) are NEVER
|
|
429
437
|
# suppressed — they represent failures the user cannot
|
|
@@ -443,17 +451,26 @@ module Rigor
|
|
|
443
451
|
end
|
|
444
452
|
end
|
|
445
453
|
|
|
446
|
-
|
|
454
|
+
# Every suppression-recognition pattern below is anchored with `\A` against the COMMENT SLICE —
|
|
455
|
+
# Prism hands us a slice that starts at the `#`, so `\A#` means "the marker is the first thing in
|
|
456
|
+
# the comment". A real directive always has that shape, in both the whole-line form and the
|
|
457
|
+
# trailing `code # rigor:disable <rule>` form; prose that merely quotes a directive
|
|
458
|
+
# (`... like `# rigor:disable-file all` ...`) has text before the inner `#` and no longer
|
|
459
|
+
# activates anything. Two consequences worth naming: a doc-tool `##` comment never activates (the
|
|
460
|
+
# second `#` is neither whitespace nor the marker word), and an `=begin`/`=end` block comment
|
|
461
|
+
# never activates either (its slice starts at `=begin`). Unanchored, these patterns silently
|
|
462
|
+
# file-suppressed this very file — see issue #306.
|
|
463
|
+
LINE_SUPPRESSION_PATTERN = /\A#\s*rigor:disable(?!-file)\s+(?<rules>[\w.,\s-]+)/
|
|
447
464
|
private_constant :LINE_SUPPRESSION_PATTERN
|
|
448
465
|
|
|
449
|
-
FILE_SUPPRESSION_PATTERN =
|
|
466
|
+
FILE_SUPPRESSION_PATTERN = /\A#\s*rigor:disable-file\s+(?<rules>[\w.,\s-]+)/
|
|
450
467
|
private_constant :FILE_SUPPRESSION_PATTERN
|
|
451
468
|
|
|
452
469
|
# A `rigor:disable[-file]` marker word regardless of whether any rule tokens follow. Used only by the
|
|
453
470
|
# `suppression.empty` detection — the two suppression patterns above require at least one token
|
|
454
471
|
# character, so a bare `# rigor:disable` never reaches them. The lookahead keeps
|
|
455
472
|
# `rigor:disable-something-else` from counting as a marker.
|
|
456
|
-
BARE_SUPPRESSION_MARKER =
|
|
473
|
+
BARE_SUPPRESSION_MARKER = /\A#\s*rigor:disable(?<file>-file)?(?![\w-])(?<rest>.*)/
|
|
457
474
|
private_constant :BARE_SUPPRESSION_MARKER
|
|
458
475
|
|
|
459
476
|
# A `rigor:` marker word that is NOT part of Rigor's suppression grammar but reads like an attempted
|
|
@@ -463,7 +480,7 @@ module Rigor
|
|
|
463
480
|
# surveillance they silently suppress nothing. Matches `disable-<suffix>` for any suffix other than
|
|
464
481
|
# `file`, and `enable` with or without a suffix.
|
|
465
482
|
UNKNOWN_SUPPRESSION_MARKER =
|
|
466
|
-
|
|
483
|
+
/\A#\s*rigor:(?<marker>disable-(?!file(?![\w-]))[\w-]+|enable(?:-[\w-]+)?)(?![\w-])(?<rest>.*)/
|
|
467
484
|
private_constant :UNKNOWN_SUPPRESSION_MARKER
|
|
468
485
|
|
|
469
486
|
# @return [Array<(Hash{Integer => Set}, Set)>] pair of
|
|
@@ -527,9 +544,10 @@ module Rigor
|
|
|
527
544
|
end
|
|
528
545
|
|
|
529
546
|
# A comment carrying the marker word but not the token-bearing suppression grammar. A remainder of
|
|
530
|
-
# nothing but whitespace / commas is a genuinely empty marker (`# rigor:disable`); anything else
|
|
531
|
-
#
|
|
532
|
-
#
|
|
547
|
+
# nothing but whitespace / commas is a genuinely empty marker (`# rigor:disable`); anything else is
|
|
548
|
+
# left alone as an ordinary comment, matching the parse path, which never treats it as a
|
|
549
|
+
# suppression either. Prose that merely quotes a marker is already excluded a step earlier by the
|
|
550
|
+
# `\A` anchor, since the quotation is not at the start of the comment.
|
|
533
551
|
def diagnose_bare_suppression_marker(path, comment, source, diagnostics)
|
|
534
552
|
bare = BARE_SUPPRESSION_MARKER.match(source)
|
|
535
553
|
if bare
|
|
@@ -545,8 +563,9 @@ module Rigor
|
|
|
545
563
|
|
|
546
564
|
# `# rigor:disable-next-line <rule>` / `# rigor:enable <rule>` — a marker word Rigor's grammar does
|
|
547
565
|
# not recognise but that reads as an attempted suppression (the RuboCop reflex). Fires only when the
|
|
548
|
-
# remainder is empty or looks like a rule list, so prose mentioning
|
|
549
|
-
# an ordinary comment — the same escape the empty-marker detection
|
|
566
|
+
# marker opens the comment and the remainder is empty or looks like a rule list, so prose mentioning
|
|
567
|
+
# the spelling in backticks stays an ordinary comment — the same escape the empty-marker detection
|
|
568
|
+
# observes.
|
|
550
569
|
def diagnose_unknown_suppression_marker(path, comment, source, diagnostics)
|
|
551
570
|
unknown = UNKNOWN_SUPPRESSION_MARKER.match(source)
|
|
552
571
|
return if unknown.nil?
|
|
@@ -656,6 +675,13 @@ module Rigor
|
|
|
656
675
|
# non-nil constituent (which, for a cross-file project def, would
|
|
657
676
|
# be a working-code false positive).
|
|
658
677
|
receiver_type = safe_navigation_receiver(call_node, scope)
|
|
678
|
+
|
|
679
|
+
# #320 — the private-singleton-object idiom (`class << Merger = Object.new`). The body's methods
|
|
680
|
+
# are recorded on the constant's own name, but the receiver reads back as `Object`, so the
|
|
681
|
+
# class-keyed probes below cannot see them. Recover the name from the receiver syntax. Scoped to
|
|
682
|
+
# the recorded name only: `Merger.nope` still fires.
|
|
683
|
+
return nil if Inference::SingletonObjectConstant.recorded?(call_node, receiver_type, call_node.name, scope)
|
|
684
|
+
|
|
659
685
|
class_name = concrete_class_name(receiver_type)
|
|
660
686
|
# A union receiver has no single concrete class. The scalar path
|
|
661
687
|
# below cannot reason about it, but the call is still definitely
|
|
@@ -1255,8 +1281,12 @@ module Rigor
|
|
|
1255
1281
|
# principle. The nil stays in the displayed type; only its use as
|
|
1256
1282
|
# diagnostic fuel is withheld. Any flow-live touch (method-local
|
|
1257
1283
|
# nil write, failed-guard narrowing) drops the mark upstream, so
|
|
1258
|
-
# flow-observed nil keeps firing exactly as before.
|
|
1259
|
-
|
|
1284
|
+
# flow-observed nil keeps firing exactly as before. The receiver
|
|
1285
|
+
# is already narrowed to a local read above, so this asks
|
|
1286
|
+
# {DeclarationSourcedGuard} exactly what it asked before — but it
|
|
1287
|
+
# now asks it through the predicate the argument-type gates share
|
|
1288
|
+
# (issue #324), which is what keeps the two rules from drifting.
|
|
1289
|
+
return nil if DeclarationSourcedGuard.marked?(call_node.receiver, scope)
|
|
1260
1290
|
|
|
1261
1291
|
# ADR-67 WD6b — an inferred-parameter receiver's type (incl. any nil constituent unioned in from a
|
|
1262
1292
|
# nil call site) is an open-call-site lower bound; a possible-nil firing against it is an FP by
|
|
@@ -2383,13 +2413,16 @@ module Rigor
|
|
|
2383
2413
|
params.map { |param| param.type.to_s.delete_prefix("::") }.uniq.join(" | ")
|
|
2384
2414
|
end
|
|
2385
2415
|
|
|
2386
|
-
# ADR-58 parity for the nil channel: a declaration-sourced
|
|
2387
|
-
#
|
|
2388
|
-
#
|
|
2389
|
-
#
|
|
2390
|
-
#
|
|
2416
|
+
# ADR-58 parity for the nil channel: a declaration-sourced read that
|
|
2417
|
+
# types as nil is the same not-diagnostic-fuel case the union path
|
|
2418
|
+
# gates in {#declaration_sourced_nil_only_mismatch?}; suppress it here
|
|
2419
|
+
# too so a ctor-seeded `@x = nil` read passed as an argument does not
|
|
2420
|
+
# fire on a working program's cross-method invariant. Issue #324 —
|
|
2421
|
+
# delegates to {DeclarationSourcedGuard}, so a local copy of the ivar
|
|
2422
|
+
# (`c = @count`) is excused on the same terms `possible-nil-receiver`
|
|
2423
|
+
# already excused it.
|
|
2391
2424
|
def declaration_sourced_nil_argument?(arg, scope)
|
|
2392
|
-
|
|
2425
|
+
DeclarationSourcedGuard.marked?(arg, scope)
|
|
2393
2426
|
end
|
|
2394
2427
|
|
|
2395
2428
|
def first_argument_mismatch(method_type, call_node, scope, param_overrides)
|
|
@@ -2452,14 +2485,15 @@ module Rigor
|
|
|
2452
2485
|
!declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
|
|
2453
2486
|
end
|
|
2454
2487
|
|
|
2455
|
-
# True when `arg` is a declaration-sourced
|
|
2488
|
+
# True when `arg` is a declaration-sourced read whose rejection is
|
|
2456
2489
|
# caused solely by its nil constituent: stripping nil from the argument
|
|
2457
2490
|
# type yields a type the parameter accepts (gradual mode). Mirrors the
|
|
2458
|
-
# `possible-nil-receiver` WD1 gate
|
|
2459
|
-
#
|
|
2491
|
+
# `possible-nil-receiver` WD1 gate — since issue #324 through the very
|
|
2492
|
+
# same {DeclarationSourcedGuard} predicate, so the ivar read and its
|
|
2493
|
+
# local copy (`c = @count`) are one case rather than two spellings that
|
|
2494
|
+
# can drift apart again.
|
|
2460
2495
|
def declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
|
|
2461
|
-
return false unless
|
|
2462
|
-
return false unless scope.declaration_sourced?(:ivar, arg.name)
|
|
2496
|
+
return false unless DeclarationSourcedGuard.marked?(arg, scope)
|
|
2463
2497
|
return false unless arg_type.is_a?(Type::Union)
|
|
2464
2498
|
return false unless union_contains_nil?(arg_type)
|
|
2465
2499
|
|
|
@@ -2720,6 +2754,12 @@ module Rigor
|
|
|
2720
2754
|
|
|
2721
2755
|
# Returns true when `override_visibility` is strictly more restrictive than `parent_visibility`
|
|
2722
2756
|
# under the public > protected > private ordering.
|
|
2757
|
+
#
|
|
2758
|
+
# The nil guard below is defence, not dead code. `VISIBILITY_RANK` is a closed literal hash read
|
|
2759
|
+
# with a dynamic key, so a symbol outside the table reads as nil at runtime. The engine folds that
|
|
2760
|
+
# read to the nil-free value union `0 | 1 | 2`, which `internal-spec/inference-engine.md` declares
|
|
2761
|
+
# OPTIMISTIC rather than proof — and since issue #313 that mark survives the `.nil?` fold and the
|
|
2762
|
+
# `||` composition, so no suppression directive is needed here.
|
|
2723
2763
|
def visibility_reduced?(parent_visibility, override_visibility)
|
|
2724
2764
|
parent_rank = VISIBILITY_RANK[parent_visibility]
|
|
2725
2765
|
override_rank = VISIBILITY_RANK[override_visibility]
|
|
@@ -5,6 +5,9 @@ require_relative "incremental"
|
|
|
5
5
|
require_relative "plugin_fact_fingerprint"
|
|
6
6
|
require_relative "../cache/file_digest"
|
|
7
7
|
require_relative "../cache/incremental_snapshot"
|
|
8
|
+
require_relative "../effects/file_collection"
|
|
9
|
+
require_relative "../effects/identity"
|
|
10
|
+
require_relative "../effects/propagator"
|
|
8
11
|
require_relative "../inference/scope_indexer"
|
|
9
12
|
|
|
10
13
|
module Rigor
|
|
@@ -105,6 +108,7 @@ module Rigor
|
|
|
105
108
|
# and a missing invalidation edge is impossible by construction — the reason this is a table diff
|
|
106
109
|
# and not the caller→callee edge recording #204 first sketched.
|
|
107
110
|
@param_table = {}
|
|
111
|
+
reset_effect_state
|
|
108
112
|
# ADR-88 WD1 — the plugin fact-surface digest computed for THIS invocation (nil until a
|
|
109
113
|
# `#run_incremental` pass runs / a plugin-free project) and the reporting flags a caller (the CLI
|
|
110
114
|
# banner + `--cache-stats`) reads after `#run_incremental`. `@last_runner` is the analysis runner the
|
|
@@ -122,6 +126,32 @@ module Rigor
|
|
|
122
126
|
@analyzed
|
|
123
127
|
end
|
|
124
128
|
|
|
129
|
+
# ADR-103 WD12 / issue #382 — the session's effect graph: the fixpoint over the merged whole,
|
|
130
|
+
# recomputed on every ask. This is the invariant that makes per-file collection reuse sound — the
|
|
131
|
+
# closure is never partially reused, only its per-file *inputs* are, so a leaf edit whose new label
|
|
132
|
+
# reaches a caller in an unchanged file still shows up in that caller's `reach`.
|
|
133
|
+
def effect_table
|
|
134
|
+
Effects::Propagator.propagate(
|
|
135
|
+
effect_collection, discharge: Effects::Discharge.new(@configuration.effects_tolerated)
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The merged direct summaries the table above closes over.
|
|
140
|
+
def effect_collection
|
|
141
|
+
Effects::FileCollection.merge_all(sorted_effect_collections)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Where each unit was defined — the same shape (and the same purpose: the snapshot's `reach:` globs)
|
|
145
|
+
# as `Runner#effect_sources`, answered from the session's own per-file collections.
|
|
146
|
+
def effect_sources
|
|
147
|
+
sorted_effect_collections.each_with_object({}) do |collection, out|
|
|
148
|
+
path = collection.path
|
|
149
|
+
next if path.nil?
|
|
150
|
+
|
|
151
|
+
collection.summaries.each_key { |key| (out[key] ||= []) << path }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
125
155
|
# Full baseline analysis with recording. Returns the run's diagnostics; populates the in-process cache
|
|
126
156
|
# + dependency state.
|
|
127
157
|
def baseline
|
|
@@ -135,6 +165,9 @@ module Rigor
|
|
|
135
165
|
# ADR-67 WD6c lift — the seed table the runner's own pre-pass computed ({} when the gate is off).
|
|
136
166
|
# Reading it back, rather than computing it here, keeps the baseline single-collect.
|
|
137
167
|
@param_table = runner.param_inferred_types
|
|
168
|
+
# ADR-103 WD13 / #382 — a baseline collects every file, so its collections are the whole world.
|
|
169
|
+
@effect_collections = runner.effect_collections_by_path
|
|
170
|
+
@effects_identity = current_effects_identity
|
|
138
171
|
@cache = per_file(diagnostics)
|
|
139
172
|
@digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
|
|
140
173
|
diagnostics
|
|
@@ -417,10 +450,18 @@ module Rigor
|
|
|
417
450
|
# (types with no fingerprint surface) make the snapshot un-reusable.
|
|
418
451
|
if restored
|
|
419
452
|
restore(restored)
|
|
453
|
+
# ADR-103 WD13 / #382 — the effects sidecar joins the same reuse decision, for a structurally
|
|
454
|
+
# identical reason: a snapshot whose summaries were collected under a different vocabulary /
|
|
455
|
+
# catalogue / `effects:` block restores as empty, and a recheck only re-collects the changed
|
|
456
|
+
# closure, so the merged table would be missing every unchanged file. A full baseline is the
|
|
457
|
+
# honest answer — it is what "recompute effects" means when collecting requires analysing, and
|
|
458
|
+
# it is paid on the first collecting run and on an identity change, never with collection off.
|
|
459
|
+
# Asked BEFORE the recheck, which re-stamps the session with the current identity.
|
|
460
|
+
effects_reusable = effects_reuse_permitted?(restored)
|
|
420
461
|
result = recheck
|
|
421
462
|
adopt_plugin_fact_fingerprint
|
|
422
|
-
|
|
423
|
-
if
|
|
463
|
+
fact_reusable = @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
|
|
464
|
+
if fact_reusable && effects_reusable
|
|
424
465
|
diagnostics = result.diagnostics
|
|
425
466
|
warm = true
|
|
426
467
|
# ADR-87 WD3 — a warm recheck that changed nothing leaves the session state byte-equivalent to the
|
|
@@ -428,10 +469,12 @@ module Rigor
|
|
|
428
469
|
# A cold baseline always persists — there was no valid snapshot to reuse.
|
|
429
470
|
skip_save = result.no_change?
|
|
430
471
|
else
|
|
431
|
-
# The fact surface moved (a plugin sig/catalog edit)
|
|
432
|
-
# files the recheck merged may be stale,
|
|
472
|
+
# The fact surface moved (a plugin sig/catalog edit), a plugin is opaque, or the effects
|
|
473
|
+
# identity moved: the cached-served files the recheck merged may be stale (or, for effects, a
|
|
474
|
+
# partial collection cannot be closed), so re-analyze the whole tree. The current fact-surface
|
|
433
475
|
# digest (from the recheck runner) is unchanged by the re-analysis, so it is kept for the save.
|
|
434
|
-
|
|
476
|
+
# Only a genuine fact-surface reason sets the reporting flag the CLI banner reads.
|
|
477
|
+
@fact_surface_invalidated = true unless fact_reusable
|
|
435
478
|
diagnostics = baseline
|
|
436
479
|
warm = false
|
|
437
480
|
skip_save = false
|
|
@@ -459,6 +502,12 @@ module Rigor
|
|
|
459
502
|
|
|
460
503
|
private
|
|
461
504
|
|
|
505
|
+
# Path-sorted, so the fold is reproducible whatever order the files were absorbed in (the same reason
|
|
506
|
+
# `Runner#effect_collections` sorts).
|
|
507
|
+
def sorted_effect_collections
|
|
508
|
+
@effect_collections.sort_by { |path, _| path.to_s }.map(&:last)
|
|
509
|
+
end
|
|
510
|
+
|
|
462
511
|
# ADR-88 WD1 — capture this invocation's fact-surface fingerprint (from the last analysis runner) onto the
|
|
463
512
|
# reporting ivars + the `@plugin_fact_reusable` decision object.
|
|
464
513
|
def adopt_plugin_fact_fingerprint
|
|
@@ -509,6 +558,12 @@ module Rigor
|
|
|
509
558
|
@class_decls = payload.class_decls || {}
|
|
510
559
|
@return_summaries = payload.return_summaries || {}
|
|
511
560
|
@param_table = payload.param_table || {} # ADR-67 WD6c lift — the seeds the cache was built under.
|
|
561
|
+
# ADR-103 WD13 / #382 — the effects sidecar rides its own identity, so a snapshot whose summaries
|
|
562
|
+
# were collected under a different vocabulary / catalogue / `effects:` block restores as EMPTY
|
|
563
|
+
# rather than as stale rows. {#run_incremental} turns that emptiness into a full baseline, because
|
|
564
|
+
# a partial re-collection cannot be closed into a whole-project fixpoint.
|
|
565
|
+
@effects_identity = payload.effects_identity
|
|
566
|
+
@effect_collections = effect_collections_reusable?(payload) ? payload.effect_collections.dup : {}
|
|
512
567
|
@symbol_dependents = Incremental.invert_symbols(@symbol_sources)
|
|
513
568
|
@ancestry_dependents = Incremental.invert(@ancestry_sources)
|
|
514
569
|
@negative_dependents = Incremental.invert(@missing)
|
|
@@ -522,10 +577,71 @@ module Rigor
|
|
|
522
577
|
class_decls: @class_decls, seed_bundles: @seed_bundles,
|
|
523
578
|
plugin_fact_digest: @plugin_fact_digest,
|
|
524
579
|
return_summaries: marshal_safe_return_summaries,
|
|
525
|
-
param_table: marshal_safe_param_table
|
|
580
|
+
param_table: marshal_safe_param_table,
|
|
581
|
+
effect_collections: marshal_safe_effect_collections,
|
|
582
|
+
effects_identity: @effects_identity
|
|
526
583
|
)
|
|
527
584
|
end
|
|
528
585
|
|
|
586
|
+
# ADR-103 WD13 / issue #382 — the per-file effect collections the session serves unchanged files
|
|
587
|
+
# from, and the effects identity they were collected under. Empty / nil when collection is off, which
|
|
588
|
+
# is every run that does not carry an `effects:` block. The PROPAGATED table is never kept here:
|
|
589
|
+
# {#effect_table} re-runs the fixpoint over the merged whole on every ask, because a leaf's summary
|
|
590
|
+
# reaches every caller and there is no per-file invalidation for a whole-graph closure.
|
|
591
|
+
def reset_effect_state
|
|
592
|
+
@effect_collections = {}
|
|
593
|
+
@effects_identity = nil
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Whether the effects half of a restored snapshot permits reuse. Collection off is vacuously true —
|
|
597
|
+
# the session keeps no collections, writes none, and the pre-#382 reuse decision stands unchanged.
|
|
598
|
+
def effects_reuse_permitted?(payload)
|
|
599
|
+
!@configuration.effects_enabled? || effect_collections_reusable?(payload)
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# ADR-103 WD13 / #382 — whether a restored payload's effect collections may be served this run:
|
|
603
|
+
# collection is on, and they were collected under the identity this run computes. Collection being
|
|
604
|
+
# OFF answers false and costs nothing — the session then keeps no collections, writes none, and is
|
|
605
|
+
# the pre-#382 session in every observable way.
|
|
606
|
+
def effect_collections_reusable?(payload)
|
|
607
|
+
return false unless @configuration.effects_enabled?
|
|
608
|
+
return false if payload.effects_identity.nil? || !payload.effect_collections.is_a?(Hash)
|
|
609
|
+
|
|
610
|
+
payload.effects_identity == current_effects_identity
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# The effects identity for THIS run, memoised: it digests a YAML catalogue and the `effects:` block,
|
|
614
|
+
# and a recheck asks for it on both the restore and the absorb side. nil when collection is off, so a
|
|
615
|
+
# non-collecting run never loads the catalogue at all.
|
|
616
|
+
#
|
|
617
|
+
# **Deliberately plugin-blind**, unlike the ADR-45 whole-run effects slot, whose descriptor carries
|
|
618
|
+
# {Effects::PluginFacts#digest} (#387). The two sides of this comparison sit on opposite sides of the
|
|
619
|
+
# run: the restore asks before any plugin is loaded and the save asks after, so folding the plugin
|
|
620
|
+
# facts in here would compare a blind digest against a sighted one and miss every single time. The
|
|
621
|
+
# bound it leaves — a plugin upgrade that moves a row does not invalidate an `--incremental`
|
|
622
|
+
# snapshot's effect collections — is recorded in `docs/internal-spec/effect-summaries.md`; the
|
|
623
|
+
# primary (whole-run) path has no such hole, and `--incremental` is opt-in.
|
|
624
|
+
def current_effects_identity
|
|
625
|
+
return nil unless @configuration.effects_enabled?
|
|
626
|
+
|
|
627
|
+
@current_effects_identity ||= Effects::Identity.digest(configuration: @configuration)
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# The same Marshal-clean guard {#marshal_safe_return_summaries} applies, for the same reason: a
|
|
631
|
+
# snapshot save must never raise. A dropped collection makes that file's summaries absent from the
|
|
632
|
+
# next run's merged table, which understates its effects — so it is dropped only when it genuinely
|
|
633
|
+
# will not serialise, and {FileCollection} is built to (the fork pool marshals one per file already).
|
|
634
|
+
def marshal_safe_effect_collections
|
|
635
|
+
return {} unless @configuration.effects_enabled?
|
|
636
|
+
|
|
637
|
+
@effect_collections.each_with_object({}) do |(path, collection), safe|
|
|
638
|
+
Marshal.dump(collection)
|
|
639
|
+
safe[path] = collection
|
|
640
|
+
rescue StandardError
|
|
641
|
+
next
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
|
|
529
645
|
# ADR-89 WD2 — the return summaries filtered to Marshal-clean entries. A summary's `keys` hold live
|
|
530
646
|
# `Type` objects; the common carriers (Nominal, Constant, Union, shapes, Dynamic) Marshal, but a type
|
|
531
647
|
# holding a live AST node would raise and abort the WHOLE snapshot save (a cache must never break a
|
|
@@ -568,6 +684,21 @@ module Rigor
|
|
|
568
684
|
end
|
|
569
685
|
absorb_dependency_graph(runner)
|
|
570
686
|
refresh_return_summaries(runner, analyze_set)
|
|
687
|
+
refresh_effect_collections(runner, analyze_set)
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
# ADR-103 WD13 / #382 — the effects analogue of {#refresh_return_summaries}, and the same soundness
|
|
691
|
+
# order: drop every re-analyzed file's collection first, then fold this run's harvest in. A file the
|
|
692
|
+
# recheck did NOT analyze keeps the collection the snapshot carried, which is exactly the reuse the
|
|
693
|
+
# slot exists for; a re-analyzed file that produced nothing (a parse failure, a file of constants)
|
|
694
|
+
# correctly ends up with nothing rather than with its pre-edit summaries.
|
|
695
|
+
def refresh_effect_collections(runner, analyze_set)
|
|
696
|
+
return unless @configuration.effects_enabled?
|
|
697
|
+
|
|
698
|
+
analyzed = analyze_set.to_set
|
|
699
|
+
@effect_collections.reject! { |path, _| analyzed.include?(path) }
|
|
700
|
+
@effect_collections.merge!(runner.effect_collections_by_path)
|
|
701
|
+
@effects_identity = current_effects_identity
|
|
571
702
|
end
|
|
572
703
|
|
|
573
704
|
# ADR-89 WD2 — replace the behavioural summaries of every re-analyzed file with THIS run's harvest: drop
|
|
@@ -599,6 +730,9 @@ module Rigor
|
|
|
599
730
|
@symbol_fingerprints.delete(path)
|
|
600
731
|
# ADR-89 WD2 — drop every behavioural summary a removed file provided (keys are `[path, symbol]`).
|
|
601
732
|
@return_summaries.reject! { |(summary_path, _symbol), _| summary_path == path }
|
|
733
|
+
# ADR-103 WD13 / #382 — and its effect collection, so a deleted file stops contributing summaries
|
|
734
|
+
# and edges to the merged table the fixpoint closes.
|
|
735
|
+
@effect_collections.delete(path)
|
|
602
736
|
# @class_decls is wholesale-replaced from the (removed-excluding)
|
|
603
737
|
# pre-pass in absorb_dependency_graph, and is frozen, so no delete.
|
|
604
738
|
end
|