rigortype 0.3.3 → 0.3.5
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 +860 -0
- data/data/effects/registry.yml +95 -0
- data/docs/handbook/07-rbs-and-extended.md +106 -5
- data/docs/manual/02-cli-reference.md +359 -2
- data/docs/manual/03-configuration.md +101 -1
- data/docs/manual/04-diagnostics.md +8 -3
- data/docs/manual/11-ci.md +37 -0
- data/docs/manual/12-caching.md +39 -0
- data/docs/manual/16-rbs-extended-annotations.md +90 -0
- data/docs/manual/18-removing-dead-code.md +326 -0
- data/docs/manual/19-effect-labels.md +671 -0
- data/docs/manual/README.md +10 -0
- data/docs/manual/ci-templates/README.md +9 -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/rule_ids.rb +28 -2
- 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 +168 -3
- data/lib/rigor/analysis/run_cache_key.rb +16 -0
- data/lib/rigor/analysis/run_cache_probe.rb +69 -1
- data/lib/rigor/analysis/runner/declaration_position.rb +38 -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 +93 -10
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
- data/lib/rigor/analysis/runner.rb +429 -10
- data/lib/rigor/analysis/worker_session.rb +60 -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 +245 -0
- data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
- data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
- data/lib/rigor/cli/effects_renderer.rb +92 -0
- data/lib/rigor/cli/effects_report.rb +163 -0
- data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
- data/lib/rigor/cli/unused_command.rb +288 -0
- data/lib/rigor/cli.rb +28 -1
- data/lib/rigor/configuration/severity_profile.rb +23 -0
- data/lib/rigor/configuration.rb +322 -28
- data/lib/rigor/configuration_error.rb +20 -0
- data/lib/rigor/effects/attribution.rb +76 -0
- data/lib/rigor/effects/catalog.rb +275 -0
- data/lib/rigor/effects/collector.rb +219 -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 +139 -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 +174 -0
- data/lib/rigor/effects/framework_units.rb +222 -0
- data/lib/rigor/effects/identity.rb +104 -0
- data/lib/rigor/effects/inline_anchor.rb +134 -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 +384 -0
- data/lib/rigor/effects/propagator.rb +335 -0
- data/lib/rigor/effects/registry.rb +200 -0
- data/lib/rigor/effects/scanner.rb +302 -0
- data/lib/rigor/effects/signature_sources.rb +90 -0
- data/lib/rigor/effects/snapshot.rb +396 -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 +39 -0
- data/lib/rigor/effects/unit_scan.rb +647 -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/expression_typer.rb +18 -4
- data/lib/rigor/inference/pre_eval_constants.rb +189 -0
- data/lib/rigor/inference/scope_indexer.rb +15 -3
- data/lib/rigor/plugin/base.rb +28 -0
- data/lib/rigor/plugin/box.rb +18 -2
- data/lib/rigor/plugin/effect_ancestry.rb +80 -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 +127 -3
- data/lib/rigor/plugin/registry.rb +55 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
- data/lib/rigor/rbs_extended.rb +188 -0
- data/lib/rigor/reflection.rb +131 -30
- 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 +401 -29
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
- 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-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -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.rbs +17 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- metadata +84 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../scope"
|
|
6
|
+
require_relative "../type"
|
|
7
|
+
require_relative "scope_indexer"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module Inference
|
|
11
|
+
# Issue #352 / [ADR-17](../../../docs/adr/17-monkey-patch-pre-evaluation.md) — the constant half of the
|
|
12
|
+
# `pre_eval:` publication surface.
|
|
13
|
+
#
|
|
14
|
+
# ADR-17 slice 2 gave `pre_eval:` a project-wide **method** registry ({ProjectPatchedMethods}). A
|
|
15
|
+
# constant declared in the same file stayed invisible across the file boundary: `Scope#in_source_constants`
|
|
16
|
+
# is per-file and is deliberately not part of `Runner#project_scope_seed_tables`, so only class-shaped
|
|
17
|
+
# constants (a `class` / `module` declaration, or one of the four meta-new forms `ScopeIndexer`'s
|
|
18
|
+
# `record_class_new_constant_decl` promotes) ever crossed. `TIMEOUT = 30` in a listed file read as
|
|
19
|
+
# `Dynamic[top]` everywhere else.
|
|
20
|
+
#
|
|
21
|
+
# This collector closes that half. It walks the `pre_eval:` files with the SAME constant pre-pass the
|
|
22
|
+
# per-file path uses ({ScopeIndexer.build_in_source_constants}) and publishes the result — widened — into
|
|
23
|
+
# the project seed. Files not listed under `pre_eval:` are untouched, so the feature is opt-in by
|
|
24
|
+
# construction and its cost stays proportional to the listed file count (ADR-17 WD1's cost-bounded
|
|
25
|
+
# argument, which is also why slice 5's full-project two-pass stayed rejectable).
|
|
26
|
+
#
|
|
27
|
+
# ## The widening rule — why the published type is not the same-file type
|
|
28
|
+
#
|
|
29
|
+
# Same-file constant propagation is **value-pinned**: `42`, `"hello"`, a `Tuple`, a `HashShape`. Carrying
|
|
30
|
+
# that verbatim across a file boundary would land diagnostics in files whose author never opened the
|
|
31
|
+
# constant's definition — `CONFIG = { a: 1 }` arriving as a closed `HashShape` makes `CONFIG.fetch(:b)`
|
|
32
|
+
# fire, `ARR = [1, 2]` arriving as a `Tuple` routes through `ShapeDispatch` instead of the RBS overload a
|
|
33
|
+
# call site relied on. Today's cross-file `Dynamic[top]` is false-positive-free *by construction*, so
|
|
34
|
+
# precision here can only spend that budget; AGENTS.md § "Implementation Guidelines" ranks false positives
|
|
35
|
+
# above worst-case static reading, so this slice spends as little of it as possible.
|
|
36
|
+
#
|
|
37
|
+
# {.widen} therefore publishes the **erased class**, never the value:
|
|
38
|
+
#
|
|
39
|
+
# | Declared | Same file | Published cross-file |
|
|
40
|
+
# | --- | --- | --- |
|
|
41
|
+
# | `INT_LIT = 42` | `42` | `Integer` |
|
|
42
|
+
# | `STR_LIT = "hello"` | `"hello"` | `String` |
|
|
43
|
+
# | `ARR_LIT = [1, 2]` | `[1, 2]` (Tuple) | `Array` (raw, elements dropped) |
|
|
44
|
+
# | `HSH_LIT = { a: 1 }` | `{ a: 1 }` (HashShape) | `Hash` (raw, keys dropped) |
|
|
45
|
+
# | `ALIAS_CLS = String` | `singleton(String)` | `singleton(String)` |
|
|
46
|
+
# | `Nest::NESTED_INT = 7` | `7` | `Integer` |
|
|
47
|
+
#
|
|
48
|
+
# Anything the rule does not recognise **declines** — the name is simply not published and reads exactly
|
|
49
|
+
# as it does today. Declining is always the safe answer here, which is why the `else` arm is `nil` rather
|
|
50
|
+
# than a fallback. `Constant[nil]` declines on purpose: a `X = nil` at declaration position is a
|
|
51
|
+
# placeholder for a value assigned at runtime far more often than it is a genuine `NilClass`, and
|
|
52
|
+
# publishing `NilClass` project-wide would make every use of it a diagnostic.
|
|
53
|
+
#
|
|
54
|
+
# Value-pinning a *provably* frozen single-write literal cross-file is a strictly later question; it needs
|
|
55
|
+
# its own FP measurement and is deliberately not attempted here.
|
|
56
|
+
#
|
|
57
|
+
# ## The multi-file write rule — widen on conflict, all the way to `Dynamic[top]`
|
|
58
|
+
#
|
|
59
|
+
# Within one file, `ScopeIndexer#record_constant_write` unions repeated writes; that union is widened as a
|
|
60
|
+
# whole (`X = 1; X = "a"` in one file publishes `Integer | String`). ACROSS files the same union would be
|
|
61
|
+
# a type neither author can see, so the rule is **widen on conflict**: when two listed files publish the
|
|
62
|
+
# same qualified name with different widened types, the name is dropped from the table entirely and reads
|
|
63
|
+
# as `Dynamic[top]` — the widest type there is, and the one the name already had. Agreeing writes (`X = 1`
|
|
64
|
+
# here, `X = 2` there — both `Integer`) are not a conflict at all, which is the point of widening first:
|
|
65
|
+
# `1 | 2` is never produced.
|
|
66
|
+
#
|
|
67
|
+
# ## Ordering
|
|
68
|
+
#
|
|
69
|
+
# The published table seeds `Scope#in_source_constants`, which `Reflection.constant_type_at` consults
|
|
70
|
+
# AFTER the class registry and `discovered_classes`. A published entry can therefore never mask a
|
|
71
|
+
# class-shaped constant that already crossed, and the per-file table always wins over the seed (see
|
|
72
|
+
# {ScopeIndexer.index}'s merge) — same-file remains the most specific authority.
|
|
73
|
+
module PreEvalConstants
|
|
74
|
+
EMPTY = {}.freeze
|
|
75
|
+
|
|
76
|
+
module_function
|
|
77
|
+
|
|
78
|
+
# Collects and widens the constants every `pre_eval:` file declares.
|
|
79
|
+
#
|
|
80
|
+
# @param paths [Array<String>] absolute paths to the `pre_eval:` files that exist on disk.
|
|
81
|
+
# @param scope_builder [#call] `path -> Rigor::Scope`; the caller supplies a project-seeded, environment-
|
|
82
|
+
# bound scope so the rvalue typer resolves cross-file classes exactly as per-file analysis would.
|
|
83
|
+
# @param target_ruby [String, nil] the Prism parse version (`Configuration#target_ruby`).
|
|
84
|
+
# @param buffer [Rigor::Analysis::BufferBinding, nil] editor-mode binding; when set, a listed file that
|
|
85
|
+
# matches the in-flight buffer is read from its physical bytes.
|
|
86
|
+
# @return [Hash{String => Rigor::Type}] frozen qualified-name -> published type table.
|
|
87
|
+
def collect(paths:, scope_builder:, target_ruby: nil, buffer: nil)
|
|
88
|
+
published = {}
|
|
89
|
+
conflicted = {}
|
|
90
|
+
paths.each do |path|
|
|
91
|
+
file_constants(path, scope_builder: scope_builder, target_ruby: target_ruby, buffer: buffer)
|
|
92
|
+
.each { |name, type| merge_publication(published, conflicted, name, type) }
|
|
93
|
+
end
|
|
94
|
+
published.freeze
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Folds one declaration into the accumulator under the widen-on-conflict rule. A name that has already
|
|
98
|
+
# conflicted stays out for the rest of the collection — a later agreeing write must not resurrect it.
|
|
99
|
+
def merge_publication(published, conflicted, name, type)
|
|
100
|
+
return if conflicted.key?(name)
|
|
101
|
+
|
|
102
|
+
widened = widen(type)
|
|
103
|
+
return if widened.nil?
|
|
104
|
+
|
|
105
|
+
existing = published[name]
|
|
106
|
+
return published[name] = widened if existing.nil?
|
|
107
|
+
return if existing == widened
|
|
108
|
+
|
|
109
|
+
published.delete(name)
|
|
110
|
+
conflicted[name] = true
|
|
111
|
+
end
|
|
112
|
+
private_class_method :merge_publication
|
|
113
|
+
|
|
114
|
+
# The per-file constant table, typed through the same pre-pass the per-file path runs. Deliberately NOT
|
|
115
|
+
# a full `ScopeIndexer.index`: ADR-17 WD4 keeps the pre-eval pass a discovery walk, and the constant
|
|
116
|
+
# pre-pass (plus the declaration artifacts it needs to resolve in-file class references) is exactly the
|
|
117
|
+
# discovery facet this feature consumes. Fails soft to the empty table — a pre-eval file that cannot be
|
|
118
|
+
# read or parsed already surfaces a `pre-eval.parse-error` warning from {ProjectPatchedScanner}, and it
|
|
119
|
+
# must never break the run.
|
|
120
|
+
def file_constants(path, scope_builder:, target_ruby: nil, buffer: nil)
|
|
121
|
+
physical = buffer ? buffer.resolve(path) : path
|
|
122
|
+
parse_result = Prism.parse(File.read(physical), filepath: path, version: target_ruby)
|
|
123
|
+
return EMPTY unless parse_result.errors.empty?
|
|
124
|
+
|
|
125
|
+
root = parse_result.value
|
|
126
|
+
ScopeIndexer.build_in_source_constants(root, declaration_seeded_scope(root, scope_builder.call(path)))
|
|
127
|
+
rescue StandardError
|
|
128
|
+
EMPTY
|
|
129
|
+
end
|
|
130
|
+
private_class_method :file_constants
|
|
131
|
+
|
|
132
|
+
# Mirrors the head of {ScopeIndexer.index}: seed the file's own declaration overrides and discovered
|
|
133
|
+
# classes onto the project-seeded scope so a `CONST = SomeClassDefinedRightHere.new` rvalue types the
|
|
134
|
+
# same way it does during real analysis.
|
|
135
|
+
def declaration_seeded_scope(root, scope)
|
|
136
|
+
declared_types, discovered_classes = ScopeIndexer.build_declaration_artifacts(root)
|
|
137
|
+
scope.with_discovery(
|
|
138
|
+
scope.discovery.with(
|
|
139
|
+
declared_types: declared_types,
|
|
140
|
+
discovered_classes: scope.discovered_classes.merge(discovered_classes)
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
private_class_method :declaration_seeded_scope
|
|
145
|
+
|
|
146
|
+
# The publication widening. Returns the type to publish, or `nil` to decline (the name keeps today's
|
|
147
|
+
# `Dynamic[top]` cross-file reading). See the module doc for why declining is the safe default.
|
|
148
|
+
def widen(type)
|
|
149
|
+
case type
|
|
150
|
+
when Type::Constant then widen_constant(type)
|
|
151
|
+
when Type::Refined then widen(type.base)
|
|
152
|
+
when Type::IntegerRange then Type::Combinator.nominal_of("Integer")
|
|
153
|
+
when Type::Tuple then Type::Combinator.nominal_of("Array")
|
|
154
|
+
when Type::HashShape then Type::Combinator.nominal_of("Hash")
|
|
155
|
+
when Type::Nominal then type.type_args.empty? ? type : Type::Combinator.nominal_of(type.class_name)
|
|
156
|
+
when Type::Singleton then type
|
|
157
|
+
when Type::DataInstance, Type::StructInstance then nominal_for_class_name(type.class_name)
|
|
158
|
+
when Type::Union then widen_union(type)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# `Constant[v]` publishes `v`'s class. `nil` declines (see the module doc); so does a value whose class
|
|
163
|
+
# is anonymous, which has no name to publish under.
|
|
164
|
+
def widen_constant(type)
|
|
165
|
+
return nil if type.value.nil?
|
|
166
|
+
|
|
167
|
+
nominal_for_class_name(type.value.class.name)
|
|
168
|
+
end
|
|
169
|
+
private_class_method :widen_constant
|
|
170
|
+
|
|
171
|
+
def nominal_for_class_name(class_name)
|
|
172
|
+
return nil unless class_name.is_a?(String) && !class_name.empty?
|
|
173
|
+
|
|
174
|
+
Type::Combinator.nominal_of(class_name)
|
|
175
|
+
end
|
|
176
|
+
private_class_method :nominal_for_class_name
|
|
177
|
+
|
|
178
|
+
# A union publishes only when EVERY member widens: one unrecognised arm means the union's real extent is
|
|
179
|
+
# unknown, and a partial union would be narrower than the truth — the false-positive direction.
|
|
180
|
+
def widen_union(type)
|
|
181
|
+
widened = type.members.map { |member| widen(member) }
|
|
182
|
+
return nil if widened.any?(&:nil?)
|
|
183
|
+
|
|
184
|
+
Type::Combinator.union(*widened)
|
|
185
|
+
end
|
|
186
|
+
private_class_method :widen_union
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -88,10 +88,13 @@ module Rigor
|
|
|
88
88
|
# program and types its rvalue under a scope that carries the surrounding qualified prefix as `self_type`, so
|
|
89
89
|
# the rvalue typer sees in-class references resolve correctly. Multiple writes to the same qualified name union
|
|
90
90
|
# via `Type::Combinator.union`.
|
|
91
|
+
# Issue #352 — the per-file table merges OVER whatever the base scope already published (the
|
|
92
|
+
# `pre_eval:` constant seed `Runner#project_scope_seed_tables` applies). Same-file declarations are the
|
|
93
|
+
# most specific authority, exactly as `merged_classes` above resolves the same collision. Without the
|
|
94
|
+
# merge, the assignment below would silently drop the project seed on every file.
|
|
91
95
|
in_source_constants = build_in_source_constants(root, seeded_scope)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
)
|
|
96
|
+
merged_constants = merge_seeded_constants(default_scope.in_source_constants, in_source_constants)
|
|
97
|
+
seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(in_source_constants: merged_constants))
|
|
95
98
|
|
|
96
99
|
# Slice 7 phase 12. In-source method discovery. Walks every class/module body for `Prism::DefNode` and
|
|
97
100
|
# recognised `define_method` calls and records the introduced method names. `rigor check` consults the table to
|
|
@@ -1206,6 +1209,15 @@ module Rigor
|
|
|
1206
1209
|
accumulator.freeze
|
|
1207
1210
|
end
|
|
1208
1211
|
|
|
1212
|
+
# Issue #352 — folds the project-wide `pre_eval:` constant seed under this file's own table. Returns the
|
|
1213
|
+
# per-file table unchanged (same frozen object) when nothing was seeded, so a run without `pre_eval:`
|
|
1214
|
+
# constants allocates and compares exactly what it did before.
|
|
1215
|
+
def merge_seeded_constants(seeded, per_file)
|
|
1216
|
+
return per_file if seeded.nil? || seeded.empty?
|
|
1217
|
+
|
|
1218
|
+
seeded.merge(per_file).freeze
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1209
1221
|
def walk_constant_writes(node, qualified_prefix, default_scope, accumulator)
|
|
1210
1222
|
return unless node.is_a?(Prism::Node)
|
|
1211
1223
|
|
data/lib/rigor/plugin/base.rb
CHANGED
|
@@ -710,6 +710,34 @@ module Rigor
|
|
|
710
710
|
manifest.protocol_contracts
|
|
711
711
|
end
|
|
712
712
|
|
|
713
|
+
# ADR-103 WD6 / WD10 (#387) — the plugin's effect contributions. Each defaults to the manifest field
|
|
714
|
+
# and each MAY be overridden to fold in per-project facts, exactly as {#protocol_contracts} may.
|
|
715
|
+
#
|
|
716
|
+
# {#effect_attributions} is the one that needs it: a Rails app's ActiveJob transport is whatever
|
|
717
|
+
# `config.active_job.queue_adapter` names, so rigor-activejob answers `io.db.write` for Solid Queue
|
|
718
|
+
# and `io.net` for Sidekiq by reading the project's own configuration. That read is a plugin-side I/O
|
|
719
|
+
# boundary read and must not happen on a run with effects off — {Registry#effect_contributions} is
|
|
720
|
+
# lazy for exactly that reason, and nothing asks unless the project has an `effects:` block.
|
|
721
|
+
def effect_attributions
|
|
722
|
+
manifest.effect_attributions
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
def effect_edges
|
|
726
|
+
manifest.effect_edges
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def effect_labels
|
|
730
|
+
manifest.effect_labels
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def effect_entry_points
|
|
734
|
+
manifest.effect_entry_points
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def effect_ancestry
|
|
738
|
+
manifest.effect_ancestry
|
|
739
|
+
end
|
|
740
|
+
|
|
713
741
|
# ADR-7 § "Slice 6-A/6-B" — per-plugin {IoBoundary}. Memoised so the boundary's accumulated `FileEntry`
|
|
714
742
|
# rows persist across producer invocations within the same plugin instance and feed cache invalidation
|
|
715
743
|
# via `cache_for`.
|
data/lib/rigor/plugin/box.rb
CHANGED
|
@@ -32,14 +32,30 @@ module Rigor
|
|
|
32
32
|
# Requires `feature` into the shared box exactly once. Returns true when the feature is available in the
|
|
33
33
|
# box, false when it could not be loaded (the caller then declines — never falls back to loading into the
|
|
34
34
|
# main space, which would defeat the boundary).
|
|
35
|
+
#
|
|
36
|
+
# The rescue is `::`-qualified and covers `::ScriptError`, matching {Isolation::Process.run_worker_loop}:
|
|
37
|
+
# a failed box require raises `::LoadError` — a ScriptError, not a StandardError — and a plain
|
|
38
|
+
# `rescue StandardError` lets it escape and abort the whole run instead of the clean decline.
|
|
35
39
|
def require_feature(feature)
|
|
36
40
|
@required ||= {}
|
|
37
41
|
return @required[feature] if @required.key?(feature)
|
|
38
42
|
|
|
39
43
|
shared.require(feature)
|
|
40
44
|
@required[feature] = true
|
|
41
|
-
rescue StandardError
|
|
42
|
-
@required[feature] =
|
|
45
|
+
rescue ::StandardError, ::ScriptError
|
|
46
|
+
@required[feature] = eval_require(feature)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The gem-resolving fallback: `Ruby::Box#require` resolves against the raw `$LOAD_PATH` only (it
|
|
50
|
+
# calls the C-level require directly), so a target library installed as a gem is reachable only
|
|
51
|
+
# through the box's own RubyGems — the `Kernel#require` *inside* the box. `feature` is a fixed,
|
|
52
|
+
# plugin-declared name rendered via `String#inspect` (a safe Ruby literal), so the eval carries no
|
|
53
|
+
# free input. Returns false when the box cannot load the feature either way (the caller declines).
|
|
54
|
+
def eval_require(feature)
|
|
55
|
+
shared.eval("require #{feature.inspect}") # require "active_support/inflector"
|
|
56
|
+
true
|
|
57
|
+
rescue ::StandardError, ::ScriptError
|
|
58
|
+
false
|
|
43
59
|
end
|
|
44
60
|
|
|
45
61
|
# Evaluates `code` inside the shared box and returns the result across the box boundary. The caller MUST
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "effect_attribution"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
# One entry of a plugin's `effect_ancestry:` — an ancestry edge the plugin's own gem introduces and
|
|
8
|
+
# the project's source never writes down (ADR-103 WD17; [#465](https://github.com/rigortype/rigor/issues/465)).
|
|
9
|
+
#
|
|
10
|
+
# `Effects::PluginFacts` walks the project's own `class … <` and `include` lines and nothing else,
|
|
11
|
+
# deliberately: reading the RBS ancestor chain instead would make a row's reach a function of whether
|
|
12
|
+
# anyone happened to run `rbs prototype`. The cost is that a chain which leaves project source never
|
|
13
|
+
# comes back. `class UserMailer < Devise::Mailer` is the measured case — `Devise::Mailer <
|
|
14
|
+
# ActionMailer::Base` is a line in the devise gem, so `rigor-actionmailer`'s rows stop one step short
|
|
15
|
+
# of every mailer a Devise application writes, and the five `Auth::*Controller < Devise::*Controller`
|
|
16
|
+
# subclasses beside it are entry points.
|
|
17
|
+
#
|
|
18
|
+
# A plugin that models a gem knows that gem's own inheritance. Declaring it here keeps the rule that
|
|
19
|
+
# Rigor reads *declarations* rather than RBS: the declaration simply comes from the plugin instead of
|
|
20
|
+
# from project source.
|
|
21
|
+
#
|
|
22
|
+
# ## What a claim may say
|
|
23
|
+
#
|
|
24
|
+
# `parent:` need only be a **true ancestor**, not the immediate superclass. The sole use of the
|
|
25
|
+
# ancestry is to make a row reachable, and no plugin row is ever keyed on a project class, so
|
|
26
|
+
# skipping intermediate links loses nothing — while insisting on the immediate parent would force a
|
|
27
|
+
# claim that is sometimes false: `Devise::SessionsController`'s real parent is `DeviseController`,
|
|
28
|
+
# whose own parent is `Devise.parent_controller` and therefore configurable per project. A claim that
|
|
29
|
+
# skips links MUST say so in its `why:`.
|
|
30
|
+
#
|
|
31
|
+
# ## Who may make one
|
|
32
|
+
#
|
|
33
|
+
# Bundled plugins only ({Rigor::Plugin::FirstParty}), enforced in `PluginFacts`. An ancestry claim
|
|
34
|
+
# carries no labels of its own, so it looks harmless — but it makes *other* plugins' rows reachable,
|
|
35
|
+
# and a third-party plugin asserting `Foo < ActiveRecord::Base` would pull rigor-activerecord's
|
|
36
|
+
# first-party discharging rows onto `Foo`. The `effect_root:` demotion and the `discharge:` grant
|
|
37
|
+
# both answer their own version of that question the same way.
|
|
38
|
+
class EffectAncestry
|
|
39
|
+
attr_reader :child, :parent, :why
|
|
40
|
+
|
|
41
|
+
def initialize(child:, parent:, why:)
|
|
42
|
+
@child = validate_class_name!(child, "child")
|
|
43
|
+
@parent = validate_class_name!(parent, "parent")
|
|
44
|
+
@why = validate_why!(why)
|
|
45
|
+
freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_h
|
|
49
|
+
{ "child" => @child, "parent" => @parent }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def ==(other)
|
|
53
|
+
other.is_a?(EffectAncestry) && to_h == other.to_h
|
|
54
|
+
end
|
|
55
|
+
alias eql? ==
|
|
56
|
+
|
|
57
|
+
def hash
|
|
58
|
+
to_h.hash
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def validate_class_name!(value, role)
|
|
64
|
+
name = value.to_s
|
|
65
|
+
unless EffectAttribution::CLASS_NAME.match?(name)
|
|
66
|
+
raise ArgumentError, "effect ancestry #{role} must be a class name, got #{value.inspect}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
name.dup.freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_why!(why)
|
|
73
|
+
value = why.to_s
|
|
74
|
+
raise ArgumentError, "effect ancestry #{@child} < #{@parent} needs a `why:` justification" if value.empty?
|
|
75
|
+
|
|
76
|
+
value.dup.freeze
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
# One row of a plugin's `effect_attributions:` — "a call to this receiver's method contributes these
|
|
6
|
+
# effect labels" (ADR-103 WD6 / WD10; design note § 6.6; the contract is
|
|
7
|
+
# `docs/internal-spec/plugin.md` § Effect contributions).
|
|
8
|
+
#
|
|
9
|
+
# A framework method has no body Rigor reads, so someone must colour it. Three channels can, in
|
|
10
|
+
# descending order of preference:
|
|
11
|
+
#
|
|
12
|
+
# 1. a `%a{rigor:v1:effect …}` annotation in the plugin's own `signature_paths:` RBS — tier 1, read
|
|
13
|
+
# through {Rigor::Effects::EnvelopeIndex}'s accepted stratum, and the right channel whenever the
|
|
14
|
+
# plugin already ships a signature for the method;
|
|
15
|
+
# 2. this field, for the methods RBS cannot name per app — association readers, `find_by_*`, scopes —
|
|
16
|
+
# and for classes the plugin ships no RBS for at all;
|
|
17
|
+
# 3. the project's own `effects.attribution:` table, which is the user's answer for everything left.
|
|
18
|
+
#
|
|
19
|
+
# ## The receiver
|
|
20
|
+
#
|
|
21
|
+
# `receiver` is spelled one of two ways, and the spelling picks the matching rule:
|
|
22
|
+
#
|
|
23
|
+
# - a **class name** (`"ActiveRecord::Base"`, `"I18n"`) matches the class the call's receiver
|
|
24
|
+
# projects to, **through the project's inheritance chain**: an `ActiveRecord::Base` row applies to
|
|
25
|
+
# `User.find` because the project declares `User < ApplicationRecord < ActiveRecord::Base`. This is
|
|
26
|
+
# the difference from a catalogue row, which matches its exact owner and nothing else — Ruby's core
|
|
27
|
+
# classes are leaves in practice and a framework's base class never is.
|
|
28
|
+
# - a **receiver path** (`"Rails.cache"`, `"Time.zone"`, `"Rails.application.credentials"`) matches
|
|
29
|
+
# the receiver *expression* as the syntax spells it. `Rails.cache.read` has no receiver class the
|
|
30
|
+
# typer can name — `Rails.cache` is a call, and its return type is adapter-dependent by design —
|
|
31
|
+
# so the only honest handle on it is the path that was written.
|
|
32
|
+
# - a **self path** (`"self.session"`, `"self.flash.now"`, `"self.cookies.encrypted"`) is the same
|
|
33
|
+
# thing rooted at implicit self, and is what a Rails controller's accessors actually look like:
|
|
34
|
+
# `session[:user_id] = id` is `[]=` on the result of a receiver-less `session`. A self-path row MUST
|
|
35
|
+
# name a `within:` class, because a receiver-less `session` in some other project class is a
|
|
36
|
+
# different `session` — the row applies only inside a class whose project ancestry reaches `within`.
|
|
37
|
+
#
|
|
38
|
+
# `on_result: true` shifts a class-name row one link outwards: it matches a call on **what a call to
|
|
39
|
+
# that class returned**. `UserMailer.welcome(u).deliver_now` and `WelcomeJob.set(wait: 1.hour)
|
|
40
|
+
# .perform_later` are the two idioms that need it — the object in the middle is a lazy
|
|
41
|
+
# `MessageDelivery` / `ConfiguredJob` whose type nothing in the project declares, while the class that
|
|
42
|
+
# produced it is written right there in the source. Without it the send and the enqueue, the two calls
|
|
43
|
+
# a reviewer most wants coloured, would go unattributed in the spelling Rails actually uses.
|
|
44
|
+
#
|
|
45
|
+
# ## Discharge
|
|
46
|
+
#
|
|
47
|
+
# `discharge: true` says the label is derived from the framework's own semantics rather than guessed,
|
|
48
|
+
# so the site is exhaustive rather than tainted. ADR-103 WD6 grants that only to a **first-party
|
|
49
|
+
# bundled** plugin ({FirstParty}), gated by `make check-plugins`; a third-party plugin's `true` is
|
|
50
|
+
# ignored with a load-time warning and the row behaves like the project's `effects.attribution:`
|
|
51
|
+
# table — declared, and carrying a `plugin-attribution` taint.
|
|
52
|
+
#
|
|
53
|
+
# Either way the labels land in the **declared** lane, never the proven one. A discharging row is a
|
|
54
|
+
# trusted claim, exactly like an accepted signature's `%a{…}`: "this is what it does", not "the
|
|
55
|
+
# analyzer read the body and saw this".
|
|
56
|
+
class EffectAttribution
|
|
57
|
+
# A receiver spelled as a `Constant::Path` — the class-name form. Anything else with a `.` in it is
|
|
58
|
+
# read as a receiver path.
|
|
59
|
+
CLASS_NAME = /\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*\z/
|
|
60
|
+
|
|
61
|
+
# A receiver path: a constant head followed by one or more sends (`Rails.cache`,
|
|
62
|
+
# `Rails.application.credentials`).
|
|
63
|
+
RECEIVER_PATH = /\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*(\.[a-z_][A-Za-z0-9_]*)+\z/
|
|
64
|
+
|
|
65
|
+
# A self path: implicit self followed by one or more receiver-less sends (`self.flash.now`).
|
|
66
|
+
SELF_PATH = /\Aself(\.[a-z_][A-Za-z0-9_]*)+\z/
|
|
67
|
+
|
|
68
|
+
# What a self path is rooted at, once the `self.` head is stripped.
|
|
69
|
+
SELF_HEAD = "self"
|
|
70
|
+
|
|
71
|
+
attr_reader :receiver, :method, :singleton, :labels, :narrow, :discharge, :within, :on_result,
|
|
72
|
+
:taint, :why
|
|
73
|
+
|
|
74
|
+
# The taint causes a plugin row may name. A closed subset of
|
|
75
|
+
# {Rigor::Effects::TaintCause::ALL}: a plugin may say "and there is more here I cannot see", but
|
|
76
|
+
# only for the two reasons a framework model can honestly have — a template it does not read, and a
|
|
77
|
+
# callable whose body is supplied by the application.
|
|
78
|
+
TAINT_CAUSES = %w[template-not-analysed opaque-callable].freeze
|
|
79
|
+
|
|
80
|
+
# @param receiver [String] a class name or a receiver path (see above)
|
|
81
|
+
# @param method [Symbol, String] the selector this row colours
|
|
82
|
+
# @param singleton [Boolean] whether the row is `Receiver.method` rather than `Receiver#method`.
|
|
83
|
+
# Meaningless — and ignored — for a receiver path, whose head already fixes the receiver object.
|
|
84
|
+
# @param labels [Array<String>] the effect labels the call contributes
|
|
85
|
+
# @param narrow [String, nil] a {Rigor::Effects::Narrowing} handler name, when the call's own
|
|
86
|
+
# argument literals settle a question the row cannot (`connection.execute("SELECT …")`)
|
|
87
|
+
# @param discharge [Boolean] see above; honoured only for a first-party bundled plugin
|
|
88
|
+
# @param why [String] the audit justification, required exactly as `data/effects/core.yml` requires
|
|
89
|
+
# one of every row: a label with no stated reason is a claim nobody can review.
|
|
90
|
+
def initialize(receiver:, method:, labels:, why:, singleton: false, narrow: nil, discharge: false, # rubocop:disable Metrics/ParameterLists
|
|
91
|
+
within: nil, on_result: false, taint: nil)
|
|
92
|
+
@receiver = validate_receiver!(receiver)
|
|
93
|
+
@method = method.to_sym
|
|
94
|
+
@singleton = singleton ? true : false
|
|
95
|
+
@labels = normalize_labels(labels)
|
|
96
|
+
@narrow = narrow.nil? ? nil : narrow.to_s.dup.freeze
|
|
97
|
+
@discharge = discharge ? true : false
|
|
98
|
+
@within = validate_within!(within)
|
|
99
|
+
@on_result = on_result ? true : false
|
|
100
|
+
validate_on_result!
|
|
101
|
+
@taint = validate_taint!(taint)
|
|
102
|
+
@why = validate_why!(why)
|
|
103
|
+
freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Whether {#receiver} is a receiver path (`Rails.cache`) rather than a class name or a self path.
|
|
107
|
+
def receiver_path?
|
|
108
|
+
@receiver.include?(".") && !self_path?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Whether {#receiver} is a self path (`self.flash.now`).
|
|
112
|
+
def self_path?
|
|
113
|
+
@receiver.start_with?("#{SELF_HEAD}.")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The key an origin and a report spell this row as.
|
|
117
|
+
def key
|
|
118
|
+
return "#{@receiver}.#{@method}" if receiver_path? || self_path?
|
|
119
|
+
return "#{@receiver}()##{@method}" if @on_result
|
|
120
|
+
|
|
121
|
+
"#{@receiver}#{@singleton ? '.' : '#'}#{@method}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def to_h
|
|
125
|
+
{
|
|
126
|
+
"receiver" => @receiver, "method" => @method.to_s, "singleton" => @singleton,
|
|
127
|
+
"labels" => @labels, "narrow" => @narrow, "discharge" => @discharge, "within" => @within,
|
|
128
|
+
"on_result" => @on_result, "taint" => @taint
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def ==(other)
|
|
133
|
+
other.is_a?(EffectAttribution) && to_h == other.to_h
|
|
134
|
+
end
|
|
135
|
+
alias eql? ==
|
|
136
|
+
|
|
137
|
+
def hash
|
|
138
|
+
to_h.hash
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def validate_receiver!(receiver)
|
|
144
|
+
value = receiver.to_s
|
|
145
|
+
unless CLASS_NAME.match?(value) || RECEIVER_PATH.match?(value) || SELF_PATH.match?(value)
|
|
146
|
+
raise ArgumentError,
|
|
147
|
+
"effect attribution receiver must be a class name, a receiver path or a self path, " \
|
|
148
|
+
"got #{receiver.inspect}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
value.dup.freeze
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# A self path with no `within:` would colour a receiver-less `session` in any project class at all.
|
|
155
|
+
def validate_within!(within)
|
|
156
|
+
if within.nil?
|
|
157
|
+
raise ArgumentError, "effect attribution #{key} is a self path and must name a `within:` class" if self_path?
|
|
158
|
+
|
|
159
|
+
return nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
value = within.to_s
|
|
163
|
+
raise ArgumentError, "effect attribution `within:` must be a class name, got #{within.inspect}" unless
|
|
164
|
+
CLASS_NAME.match?(value)
|
|
165
|
+
|
|
166
|
+
value.dup.freeze
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def normalize_labels(labels)
|
|
170
|
+
list = Array(labels).map { |label| label.to_s.dup.freeze }
|
|
171
|
+
raise ArgumentError, "effect attribution for #{@receiver} must declare at least one label" if list.empty?
|
|
172
|
+
|
|
173
|
+
list.uniq.sort.freeze
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# `on_result:` shifts the match one link outwards, which only a class-name row can do: a receiver
|
|
177
|
+
# path already names the object, and a self path already names the frame.
|
|
178
|
+
def validate_on_result!
|
|
179
|
+
return unless @on_result
|
|
180
|
+
return unless receiver_path? || self_path?
|
|
181
|
+
|
|
182
|
+
raise ArgumentError, "effect attribution #{key} may not combine `on_result:` with a path receiver"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# A row may state a bound AND say the bound is not the whole story. `render` is the case that needs
|
|
186
|
+
# it: what the controller does IS `mutate.self` + `rails.response.write`, and what the TEMPLATE does
|
|
187
|
+
# is unknown until views become effect units (ADR-103 WD11). Reporting only the first would be a
|
|
188
|
+
# summary that reads exhaustive and is not.
|
|
189
|
+
def validate_taint!(taint)
|
|
190
|
+
return nil if taint.nil?
|
|
191
|
+
|
|
192
|
+
value = taint.to_s
|
|
193
|
+
return value.dup.freeze if TAINT_CAUSES.include?(value)
|
|
194
|
+
|
|
195
|
+
raise ArgumentError,
|
|
196
|
+
"effect attribution #{key} may only taint with one of #{TAINT_CAUSES.inspect}, " \
|
|
197
|
+
"got #{taint.inspect}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def validate_why!(why)
|
|
201
|
+
value = why.to_s
|
|
202
|
+
raise ArgumentError, "effect attribution #{key} needs a `why:` justification" if value.empty?
|
|
203
|
+
|
|
204
|
+
value.dup.freeze
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|