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
data/lib/rigor/configuration.rb
CHANGED
|
@@ -6,6 +6,9 @@ require_relative "bleeding_edge"
|
|
|
6
6
|
require_relative "ci_detector"
|
|
7
7
|
require_relative "configuration/dependencies"
|
|
8
8
|
require_relative "configuration/severity_profile"
|
|
9
|
+
require_relative "effects/entry_points"
|
|
10
|
+
require_relative "effects/label"
|
|
11
|
+
require_relative "effects/method_key"
|
|
9
12
|
|
|
10
13
|
module Rigor
|
|
11
14
|
class Configuration # rubocop:disable Metrics/ClassLength
|
|
@@ -22,6 +25,15 @@ module Rigor
|
|
|
22
25
|
# fixture paths still work; the discovery list is the canonical source.
|
|
23
26
|
DEFAULT_PATH = DISCOVERY_ORDER.first
|
|
24
27
|
|
|
28
|
+
# ADR-103 WD7 — where `rigor effects update` writes when `effects.snapshot.path:` is unset. Public
|
|
29
|
+
# because the schema pins its own `default` against it.
|
|
30
|
+
DEFAULT_EFFECTS_SNAPSHOT_PATH = ".rigor-effects.yml"
|
|
31
|
+
|
|
32
|
+
# ADR-103 WD15 — the {BleedingEdge::FEATURES} id that, when adopted, makes a loaded file with no
|
|
33
|
+
# `effects:` key behave as `effects: {}`. See {#coerce_effects}.
|
|
34
|
+
EFFECTS_ON_BY_DEFAULT_FEATURE_ID = "effects-on-by-default"
|
|
35
|
+
private_constant :EFFECTS_ON_BY_DEFAULT_FEATURE_ID
|
|
36
|
+
|
|
25
37
|
# Built-in exclusion patterns appended to `exclude:` so vendored dependencies, Bundler artefacts, and
|
|
26
38
|
# JavaScript node_modules are never analysed by accident when a directory glob expands. Users cannot
|
|
27
39
|
# disable these defaults; the trade-off is that analysing any of these paths is essentially never what
|
|
@@ -65,6 +77,24 @@ module Rigor
|
|
|
65
77
|
# lifted): the incremental session recomputes the table each run and diffs it against its snapshot,
|
|
66
78
|
# re-checking any callee whose seeds moved.
|
|
67
79
|
"parameter_inference" => false,
|
|
80
|
+
# ADR-103 WD13 — the effect-labels opt-in, and the ONLY thing that turns effect collection on
|
|
81
|
+
# besides running `rigor effects`. **Presence is the switch**: `effects: {}` — or the bare key, which
|
|
82
|
+
# YAML parses as nil — enables collection with every sub-key at its default, because an annotation in
|
|
83
|
+
# a project's RBS must never create a project-wide cost cliff (that case earns a `:info` residual
|
|
84
|
+
# instead, #384).
|
|
85
|
+
#
|
|
86
|
+
# The default is `false` rather than `nil` precisely because presence is the switch and
|
|
87
|
+
# `Configuration.load` merges these DEFAULTS UNDER the loaded file: a `nil` default would be
|
|
88
|
+
# indistinguishable from a user's bare `effects:` key and would turn collection on for every project
|
|
89
|
+
# in existence. `false` is also the explicit-disable form a `.rigor.yml` uses to override an upstream
|
|
90
|
+
# `.rigor.dist.yml`'s `effects:` block — the same shape `baseline:` uses.
|
|
91
|
+
#
|
|
92
|
+
# The sub-keys are declared in `schemas/rigor-config.schema.json`. `check` (#383,
|
|
93
|
+
# {#effects_check?}), `snapshot.{path,reach,gate}`, `tolerated` (#381) and the policy trio
|
|
94
|
+
# `labels` / `attribution` / `envelopes` (#385, {#coerce_effects_policy}) are read; `views` lands
|
|
95
|
+
# with the slice that implements it (#390). Reserving a sub-key's shape before a reader exists is
|
|
96
|
+
# the same discipline the schema applies to a sibling implementation's namespace (ADR-99).
|
|
97
|
+
"effects" => false,
|
|
68
98
|
"cache" => {
|
|
69
99
|
"path" => ".rigor/cache",
|
|
70
100
|
# LRU eviction cap in bytes (ADR-54 WD3). The least-recently-used entries are removed at the end of a
|
|
@@ -211,7 +241,27 @@ module Rigor
|
|
|
211
241
|
:dependencies, :parallel_workers,
|
|
212
242
|
:bundler_bundle_path, :bundler_auto_detect, :bundler_lockfile,
|
|
213
243
|
:rbs_collection_lockfile, :rbs_collection_auto_detect,
|
|
214
|
-
:pre_eval, :baseline_path
|
|
244
|
+
:pre_eval, :baseline_path, :effects,
|
|
245
|
+
:effects_snapshot_path, :effects_snapshot_reach, :effects_snapshot_gate, :effects_tolerated,
|
|
246
|
+
:effects_labels, :effects_attribution, :effects_envelopes
|
|
247
|
+
|
|
248
|
+
# ADR-103 WD13 — whether effect collection runs. True exactly when the loaded configuration carried an
|
|
249
|
+
# `effects:` block, whatever its body; `rigor effects` enables it for its own run by loading an
|
|
250
|
+
# implicit `effects: {}` instead of by consulting anything else. Nothing else — no annotation, no
|
|
251
|
+
# plugin, no severity profile — can turn collection on.
|
|
252
|
+
def effects_enabled?
|
|
253
|
+
!@effects.nil?
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# ADR-103 WD14 / #383 — whether author-declared effect envelopes (`%a{pure}`,
|
|
257
|
+
# `%a{rigor:v1:effect …}`) are checked against the collected summaries, surfacing
|
|
258
|
+
# `effect.envelope-exceeded`. Defaults to **true when the `effects:` block is present**: the block is
|
|
259
|
+
# the opt-in, and a project that asked for effects asked for its own declarations to hold. An explicit
|
|
260
|
+
# `check: false` disables the diagnostic and leaves collection — and therefore `rigor effects` and the
|
|
261
|
+
# snapshot — untouched. Always false without the block, so an annotation alone changes nothing.
|
|
262
|
+
def effects_check?
|
|
263
|
+
@effects_check
|
|
264
|
+
end
|
|
215
265
|
|
|
216
266
|
# Loads a configuration file.
|
|
217
267
|
#
|
|
@@ -226,13 +276,19 @@ module Rigor
|
|
|
226
276
|
# [PHPStan](https://phpstan.org/config-reference#paths).
|
|
227
277
|
def self.load(path = nil)
|
|
228
278
|
resolved = path || discover
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
279
|
+
if resolved.nil? || !File.exist?(resolved)
|
|
280
|
+
data = DEFAULTS
|
|
281
|
+
effects_key_present = false
|
|
282
|
+
else
|
|
283
|
+
# ADR-103 WD15 — captured from the RAW, pre-`DEFAULTS.merge` file (+ its `includes:` chain) because
|
|
284
|
+
# `DEFAULTS` itself carries an `"effects" => false` entry: once merged, "the file never wrote
|
|
285
|
+
# `effects:`" and "the file wrote `effects: false`" collapse to the same value and become
|
|
286
|
+
# indistinguishable to {#initialize}. This is the only place that distinction still exists.
|
|
287
|
+
raw = load_with_includes(resolved)
|
|
288
|
+
effects_key_present = raw.key?("effects")
|
|
289
|
+
data = DEFAULTS.merge(raw)
|
|
290
|
+
end
|
|
291
|
+
new(autowire_default_plugins(data), effects_key_present)
|
|
236
292
|
end
|
|
237
293
|
|
|
238
294
|
# ADR-93 WD2 — the one bundled plugin default-wired without a `plugins:` entry. `rigor-rbs-inline` is the
|
|
@@ -394,7 +450,17 @@ module Rigor
|
|
|
394
450
|
:merge_value, :merge_dependencies_hash
|
|
395
451
|
|
|
396
452
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
397
|
-
|
|
453
|
+
#
|
|
454
|
+
# @param effects_key_present [Boolean] ADR-103 WD15 — whether the file this `data` came from carried an
|
|
455
|
+
# `effects:` key at all, independent of its value. Defaults to `data.key?("effects")`, which is the
|
|
456
|
+
# right answer for a caller that passes a raw (non-`DEFAULTS`-merged) hash directly — the common shape
|
|
457
|
+
# in specs — but is always `true` once `data` has been through `DEFAULTS.merge`, because `DEFAULTS`
|
|
458
|
+
# itself carries the key; {.load} therefore computes this from the pre-merge file and passes it
|
|
459
|
+
# explicitly. See {#coerce_effects}. **Positional, deliberately not a keyword**: a bare (unbraced)
|
|
460
|
+
# `"key" => value` hash literal — this class's usual call shape, all over the spec suite — is coerced
|
|
461
|
+
# into keyword arguments by Ruby whenever the method declares ANY keyword parameter, which would break
|
|
462
|
+
# every `Configuration.new("some_key" => value)` call site with an "unknown keyword" `ArgumentError`.
|
|
463
|
+
def initialize(data = DEFAULTS, effects_key_present = data.key?("effects"))
|
|
398
464
|
# Record before the per-key fetches below discard the evidence. Top level only, deliberately —
|
|
399
465
|
# see {ConfigAudit.unknown_key_warnings} for why a nested check cannot key on DEFAULTS.
|
|
400
466
|
@unknown_keys = (data.keys.map(&:to_s) - KNOWN_KEYS).sort.freeze
|
|
@@ -423,6 +489,18 @@ module Rigor
|
|
|
423
489
|
# ADR-67 WD6a — resolve to a strict Boolean so a truthy non-`true` value (e.g. a stray String) does not
|
|
424
490
|
# silently enable the gate; only the literal `true` activates the check-walk collector pre-pass.
|
|
425
491
|
@parameter_inference = data.fetch("parameter_inference", DEFAULTS.fetch("parameter_inference")) == true
|
|
492
|
+
# Resolved before `@effects` — ADR-103 WD15's "effects-on-by-default" reads {#bleeding_edge_active?},
|
|
493
|
+
# which reads `@bleeding_edge_active_ids`.
|
|
494
|
+
@bleeding_edge = coerce_bleeding_edge(
|
|
495
|
+
data.fetch("bleeding_edge", DEFAULTS.fetch("bleeding_edge"))
|
|
496
|
+
)
|
|
497
|
+
@bleeding_edge_severity_overrides = BleedingEdge.severity_overrides_for(@bleeding_edge)
|
|
498
|
+
@bleeding_edge_active_ids = BleedingEdge.active_ids_for(@bleeding_edge)
|
|
499
|
+
# ADR-103 WD13 — presence, not truthiness: `effects:` written with no body parses as nil and still
|
|
500
|
+
# means "on", so the key's presence in the loaded data is what {#effects_enabled?} reads.
|
|
501
|
+
@effects = coerce_effects(data, effects_key_present: effects_key_present)
|
|
502
|
+
coerce_effects_snapshot(@effects)
|
|
503
|
+
coerce_effects_policy(@effects)
|
|
426
504
|
@cache_path = cache.fetch("path").to_s
|
|
427
505
|
raw_max = cache.fetch("max_bytes")
|
|
428
506
|
@cache_max_bytes = raw_max.nil? ? nil : Integer(raw_max)
|
|
@@ -436,11 +514,6 @@ module Rigor
|
|
|
436
514
|
@severity_overrides = coerce_severity_overrides(
|
|
437
515
|
data.fetch("severity_overrides", DEFAULTS.fetch("severity_overrides"))
|
|
438
516
|
)
|
|
439
|
-
@bleeding_edge = coerce_bleeding_edge(
|
|
440
|
-
data.fetch("bleeding_edge", DEFAULTS.fetch("bleeding_edge"))
|
|
441
|
-
)
|
|
442
|
-
@bleeding_edge_severity_overrides = BleedingEdge.severity_overrides_for(@bleeding_edge)
|
|
443
|
-
@bleeding_edge_active_ids = BleedingEdge.active_ids_for(@bleeding_edge)
|
|
444
517
|
@dependencies = Dependencies.from_h(
|
|
445
518
|
data.fetch("dependencies", DEFAULTS.fetch("dependencies"))
|
|
446
519
|
)
|
|
@@ -576,8 +649,217 @@ module Rigor
|
|
|
576
649
|
copy.freeze
|
|
577
650
|
end
|
|
578
651
|
|
|
652
|
+
# ADR-103 WD14 — the ad-hoc opt-in `rigor effects` uses when the project's configuration carries no
|
|
653
|
+
# `effects:` block: a sibling Configuration with an implicit empty block, every other field shared.
|
|
654
|
+
# A configuration that already enables effects is returned unchanged, so a project's own settings
|
|
655
|
+
# always win over the implicit ones.
|
|
656
|
+
#
|
|
657
|
+
# Same `dup` + re-`freeze` shape as {#with_bleeding_edge}: every other ivar is the receiver's deeply
|
|
658
|
+
# frozen value, safe to share read-only, and the result stays `Ractor.shareable?` for the worker path.
|
|
659
|
+
def with_effects_enabled
|
|
660
|
+
return self if effects_enabled?
|
|
661
|
+
|
|
662
|
+
copy = dup
|
|
663
|
+
copy.instance_variable_set(:@effects, {}.freeze)
|
|
664
|
+
# The implicit block defaults like a written one. `rigor effects` emits no diagnostic either way,
|
|
665
|
+
# but the two configurations must not differ in a field a later reader could branch on.
|
|
666
|
+
copy.instance_variable_set(:@effects_check, true)
|
|
667
|
+
copy.freeze
|
|
668
|
+
end
|
|
669
|
+
|
|
579
670
|
private
|
|
580
671
|
|
|
672
|
+
# ADR-103 WD13 — the `effects:` block, or nil when the key is absent or explicitly `false`. A
|
|
673
|
+
# present-but-empty key (`effects:` alone, which YAML parses as nil) and `effects: {}` are the same
|
|
674
|
+
# thing: collection on, every sub-key defaulted. A non-Hash body is normalised to `{}` rather than
|
|
675
|
+
# rejected — the sub-keys have no reader yet, so there is nothing a malformed body could break, and
|
|
676
|
+
# tier 1 (the schema) is where its shape is answered.
|
|
677
|
+
#
|
|
678
|
+
# ADR-103 WD15 — a loaded file that carries no `effects:` key AT ALL (`effects_key_present == false`,
|
|
679
|
+
# so `value` can only be the `false` DEFAULTS fallback here, never a user's own explicit `false`) behaves
|
|
680
|
+
# as `effects: {}` when the "effects-on-by-default" bleeding-edge feature is adopted. An explicit
|
|
681
|
+
# `effects: false` — `effects_key_present == true` — always stays off regardless of the feature, and a
|
|
682
|
+
# written block always wins outright: the feature can only fill an *absence*.
|
|
683
|
+
def coerce_effects(data, effects_key_present:)
|
|
684
|
+
value = data.fetch("effects", false)
|
|
685
|
+
return {}.freeze if value == false && !effects_key_present && effects_forced_on_by_bleeding_edge?
|
|
686
|
+
return nil if value == false
|
|
687
|
+
|
|
688
|
+
(value.is_a?(Hash) ? value : {}).freeze
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# ADR-103 WD15 — guarded by {BleedingEdge.known_id?} rather than calling {#bleeding_edge_active?}
|
|
692
|
+
# directly: that predicate RAISES for an id neither queued nor graduated, which is correct for a
|
|
693
|
+
# contributor's typo against the registry in this same checkout, but a spec that stubs
|
|
694
|
+
# `BleedingEdge::FEATURES` down to an unrelated single feature (a common pattern for isolating the
|
|
695
|
+
# selector plumbing — see `spec/rigor/configuration_spec.rb`) must not have Configuration construction
|
|
696
|
+
# explode merely because the id this internal default-application reads is not the one under test.
|
|
697
|
+
def effects_forced_on_by_bleeding_edge?
|
|
698
|
+
BleedingEdge.known_id?(EFFECTS_ON_BY_DEFAULT_FEATURE_ID) &&
|
|
699
|
+
bleeding_edge_active?(EFFECTS_ON_BY_DEFAULT_FEATURE_ID)
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# ADR-103 WD7 / WD14 — the snapshot keys and the minimal `tolerated:` policy list (#381).
|
|
703
|
+
#
|
|
704
|
+
# Every key resolves to its default when `effects:` is absent, so a consumer reads one uniform surface
|
|
705
|
+
# and never has to ask whether the block was there. The values are read by `rigor effects update` /
|
|
706
|
+
# `check` / `diff` / `explain` and by nothing on `rigor check`'s path.
|
|
707
|
+
#
|
|
708
|
+
# Validation is **tier 2** (`ArgumentError`, the run stops), because each of these is a value the
|
|
709
|
+
# snapshot commands cannot proceed on: an unknown `gate:` would silently pick a semantics, an unknown
|
|
710
|
+
# preset name would silently match no file, and a malformed label would silently tolerate nothing.
|
|
711
|
+
# A label that is well-formed but unknown to the *registry* is deliberately NOT rejected here — that is
|
|
712
|
+
# `effect.unknown-label`'s job (#384), and it fails open.
|
|
713
|
+
def coerce_effects_snapshot(effects)
|
|
714
|
+
@effects_check = coerce_effects_check?(effects)
|
|
715
|
+
snapshot = effects&.fetch("snapshot", nil)
|
|
716
|
+
snapshot = {} unless snapshot.is_a?(Hash)
|
|
717
|
+
@effects_snapshot_path = (snapshot.fetch("path", nil) || DEFAULT_EFFECTS_SNAPSHOT_PATH).to_s.freeze
|
|
718
|
+
@effects_snapshot_reach = coerce_effects_reach(snapshot.fetch("reach", nil))
|
|
719
|
+
@effects_snapshot_gate = coerce_effects_gate(snapshot.fetch("gate", nil))
|
|
720
|
+
@effects_tolerated = coerce_effects_tolerated(effects&.fetch("tolerated", nil))
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# `effects.check:` — true by default under a present block, and never true without one. Only an
|
|
724
|
+
# explicit `false` turns it off; any other value is normalised to true rather than rejected, because a
|
|
725
|
+
# switch whose only two readings are "check" and "do not check" has nothing a tier-2 error would add.
|
|
726
|
+
def coerce_effects_check?(effects)
|
|
727
|
+
return false if effects.nil?
|
|
728
|
+
|
|
729
|
+
effects.fetch("check", true) != false
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
VALID_EFFECTS_GATES = %i[symmetric additions].freeze
|
|
733
|
+
private_constant :VALID_EFFECTS_GATES
|
|
734
|
+
|
|
735
|
+
def coerce_effects_gate(value)
|
|
736
|
+
return :symmetric if value.nil?
|
|
737
|
+
|
|
738
|
+
gate = value.to_s.to_sym
|
|
739
|
+
unless VALID_EFFECTS_GATES.include?(gate)
|
|
740
|
+
raise ArgumentError,
|
|
741
|
+
"effects.snapshot.gate must be one of #{VALID_EFFECTS_GATES.inspect}, got #{value.inspect}"
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
gate
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# Entry-point globs and preset names, kept as written.
|
|
748
|
+
#
|
|
749
|
+
# **Shape only.** A preset is *named by a plugin* (#387; ADR-103 WD14) and plugins load from this very
|
|
750
|
+
# configuration, so at this point no preset is registered yet and asking `EntryPoints.known?` would
|
|
751
|
+
# reject `reach: [rails]` for every project that uses one. The existence check therefore lives where
|
|
752
|
+
# the snapshot expands `reach:` ({Effects::Snapshot.expand_reach}), which runs after plugin load and
|
|
753
|
+
# raises there — the same error, at the first moment it can be right.
|
|
754
|
+
def coerce_effects_reach(value)
|
|
755
|
+
entries = Array(value).map(&:to_s)
|
|
756
|
+
entries.each do |entry|
|
|
757
|
+
next if Effects::EntryPoints.glob?(entry) || Effects::EntryPoints.name?(entry)
|
|
758
|
+
|
|
759
|
+
raise ArgumentError,
|
|
760
|
+
"effects.snapshot.reach entry is neither a file glob nor a well-formed entry-point preset " \
|
|
761
|
+
"name: #{entry.inspect} (a preset name is #{Effects::EntryPoints::NAME_PATTERN.inspect}; " \
|
|
762
|
+
"anything carrying a path or glob character is treated as a file glob instead)"
|
|
763
|
+
end
|
|
764
|
+
entries.uniq.freeze
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
# Shape validation only: the label grammar of `docs/type-specification/effect-labels.md`.
|
|
768
|
+
def coerce_effects_tolerated(value)
|
|
769
|
+
labels = Array(value).map(&:to_s)
|
|
770
|
+
labels.each do |label|
|
|
771
|
+
raise ArgumentError, "effects.tolerated is not a well-formed effect label: #{label.inspect}" unless
|
|
772
|
+
Effects::Label.valid?(label)
|
|
773
|
+
end
|
|
774
|
+
labels.uniq.sort.freeze
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# ADR-103 WD5 / WD6 / #385 — the three policy keys: the project's own vocabulary (`labels:`), its
|
|
778
|
+
# attribution table for code Rigor does not analyse (`attribution:`), and its envelopes by convention
|
|
779
|
+
# (`envelopes:`).
|
|
780
|
+
#
|
|
781
|
+
# Validation is **tier 2** for shape and tier 2 only. A label whose SPELLING is malformed, an entry
|
|
782
|
+
# naming neither `match:` nor `namespace:` (or both), and an attribution key that is not a method key
|
|
783
|
+
# each stop the run, because none of them has a reading the loader could pick. A label that is
|
|
784
|
+
# well-formed but unknown to the *registry* is deliberately fine here: it fails open (⊤ for an
|
|
785
|
+
# envelope, an unregistered meaning for an attribution) and surfaces as `effect.unknown-label`.
|
|
786
|
+
def coerce_effects_policy(effects)
|
|
787
|
+
@effects_labels = coerce_effects_labels(effects&.fetch("labels", nil))
|
|
788
|
+
@effects_attribution = coerce_effects_attribution(effects&.fetch("attribution", nil))
|
|
789
|
+
@effects_envelopes = coerce_effects_envelopes(effects&.fetch("envelopes", nil))
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
NO_ATTRIBUTION = {}.freeze
|
|
793
|
+
private_constant :NO_ATTRIBUTION
|
|
794
|
+
|
|
795
|
+
def coerce_effects_labels(value)
|
|
796
|
+
labels = Array(value).map(&:to_s)
|
|
797
|
+
labels.each do |label|
|
|
798
|
+
raise ArgumentError, "effects.labels is not a well-formed effect label: #{label.inspect}" unless
|
|
799
|
+
Effects::Label.valid?(label)
|
|
800
|
+
end
|
|
801
|
+
labels.uniq.sort.freeze
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# `{ "Net::HTTP.get" => ["io.net.http"] }` — a method key exactly as the symbol tables spell one
|
|
805
|
+
# (`Owner#instance`, `Owner.singleton`), mapped to the labels a call to it contributes.
|
|
806
|
+
def coerce_effects_attribution(value)
|
|
807
|
+
return NO_ATTRIBUTION unless value.is_a?(Hash)
|
|
808
|
+
|
|
809
|
+
value.each_with_object({}) do |(key, labels), out|
|
|
810
|
+
name = key.to_s
|
|
811
|
+
unless Effects::MethodKey.valid?(name)
|
|
812
|
+
raise ArgumentError,
|
|
813
|
+
"effects.attribution key is not a method key (`Owner#method` / `Owner.method`): #{name.inspect}"
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
out[name] = coerce_effect_label_list(Array(labels), "effects.attribution[#{name.inspect}]")
|
|
817
|
+
end.freeze
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
# `[{ match: | namespace:, effect: [...] }]`. Exactly one selector per entry: an entry naming both
|
|
821
|
+
# would need a precedence rule between two selectors of the same entry, and one naming neither selects
|
|
822
|
+
# every class in the project, which is never what an author meant to write.
|
|
823
|
+
def coerce_effects_envelopes(value)
|
|
824
|
+
Array(value).each_with_index.map { |entry, index| coerce_effects_envelope(entry, index) }.freeze
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
def coerce_effects_envelope(entry, index)
|
|
828
|
+
where = "effects.envelopes[#{index}]"
|
|
829
|
+
raise ArgumentError, "#{where} is not a mapping: #{entry.inspect}" unless entry.is_a?(Hash)
|
|
830
|
+
|
|
831
|
+
match = coerce_effects_envelope_selector(entry["match"], "#{where}.match")
|
|
832
|
+
namespace = coerce_effects_envelope_selector(entry["namespace"], "#{where}.namespace")
|
|
833
|
+
if match.nil? == namespace.nil?
|
|
834
|
+
raise ArgumentError, "#{where} must name exactly one of `match:` (a path glob) or `namespace:` " \
|
|
835
|
+
"(a constant glob), got #{match.nil? ? 'neither' : 'both'}"
|
|
836
|
+
end
|
|
837
|
+
unless entry.key?("effect")
|
|
838
|
+
raise ArgumentError, "#{where} has no `effect:` bound (write `effect: []` for the empty envelope)"
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
{
|
|
842
|
+
"match" => match, "namespace" => namespace,
|
|
843
|
+
"effect" => coerce_effect_label_list(Array(entry["effect"]), "#{where}.effect")
|
|
844
|
+
}.freeze
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
def coerce_effects_envelope_selector(value, where)
|
|
848
|
+
return nil if value.nil?
|
|
849
|
+
|
|
850
|
+
selector = value.to_s
|
|
851
|
+
raise ArgumentError, "#{where} is empty" if selector.strip.empty?
|
|
852
|
+
|
|
853
|
+
selector.freeze
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
def coerce_effect_label_list(labels, where)
|
|
857
|
+
labels.map(&:to_s).each do |label|
|
|
858
|
+
raise ArgumentError, "#{where} is not a well-formed effect label: #{label.inspect}" unless
|
|
859
|
+
Effects::Label.valid?(label)
|
|
860
|
+
end.uniq.sort.freeze
|
|
861
|
+
end
|
|
862
|
+
|
|
581
863
|
# ADR-17 slice 4 — `pre_eval:` glob expansion. Each entry is accepted as either a literal path (slice 1
|
|
582
864
|
# contract) OR a `File.fnmatch?`-shaped glob pattern (`lib/core_ext/**/*.rb`). Glob meta characters (`*`,
|
|
583
865
|
# `?`, `[`) trigger `Dir.glob` expansion; the resulting file list is folded into the `pre_eval:` set with
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "label_set"
|
|
4
|
+
require_relative "method_key"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Effects
|
|
8
|
+
# The project's own `effects.attribution:` table — what a call into code Rigor never analysed does
|
|
9
|
+
# (ADR-103 WD5 (6) / WD6; design note § 6.6).
|
|
10
|
+
#
|
|
11
|
+
# A gem method has no body here, so someone must colour it. The catalogue ({Catalog}) answers for
|
|
12
|
+
# Ruby's own surface, a plugin answers for a framework it models, and this table is the project's
|
|
13
|
+
# answer for everything left: `attribution: {"Net::HTTP.get": [io.net.http]}`.
|
|
14
|
+
#
|
|
15
|
+
# **It never discharges the taint.** A configured attribution is an unchecked claim about code the
|
|
16
|
+
# analyzer did not read, so WD6 puts it in the *declared* lane and keeps the site's
|
|
17
|
+
# `plugin-attribution` cause: the summary reads "declared this, and possibly more" rather than
|
|
18
|
+
# "proven this". That is the whole difference between this table and a catalogue row, and it is why an
|
|
19
|
+
# envelope can never fire because of one — diagnostics read the proven lane only.
|
|
20
|
+
#
|
|
21
|
+
# The lookup is deliberately the same shape the catalogue's is: the owner the syntax names when it
|
|
22
|
+
# names one (`Net::HTTP.get`), and the class the typer projected the receiver to otherwise. So a table
|
|
23
|
+
# keyed `Logger#info` colours `logger.info(…)` on a receiver typed `Logger`, by name, without needing
|
|
24
|
+
# the dispatcher to have resolved anything.
|
|
25
|
+
class Attribution
|
|
26
|
+
EMPTY_ROWS = {}.freeze
|
|
27
|
+
private_constant :EMPTY_ROWS
|
|
28
|
+
|
|
29
|
+
def self.empty
|
|
30
|
+
@empty ||= new({})
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param table [Hash{String => Array<String>}] `Configuration#effects_attribution` — method key to
|
|
34
|
+
# label list, both already shape-validated at load (tier 2).
|
|
35
|
+
def self.build(table)
|
|
36
|
+
return empty if table.nil? || table.empty?
|
|
37
|
+
|
|
38
|
+
new(table)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(table)
|
|
42
|
+
@rows = build_rows(table)
|
|
43
|
+
freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def empty?
|
|
47
|
+
@rows.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The labels attributed to a method key (`Net::HTTP.get`), or nil when the table says nothing about
|
|
51
|
+
# it. The key is spelled by the caller, which already builds the same string for the catalogue.
|
|
52
|
+
#
|
|
53
|
+
# @return [LabelSet, nil]
|
|
54
|
+
def [](key)
|
|
55
|
+
@rows[key]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# A malformed key cannot arrive from `Configuration` (tier 2 rejects it), but this class is also
|
|
61
|
+
# constructed straight from a Hash in specs and by a future plugin channel (#387), so an
|
|
62
|
+
# unparseable key is dropped rather than raised on: attribution is an enrichment, and a bad row
|
|
63
|
+
# must not take a run down.
|
|
64
|
+
def build_rows(table)
|
|
65
|
+
return EMPTY_ROWS if table.empty?
|
|
66
|
+
|
|
67
|
+
table.each_with_object({}) do |(key, labels), out|
|
|
68
|
+
next if MethodKey.split(key.to_s).nil?
|
|
69
|
+
|
|
70
|
+
set = LabelSet.new(Array(labels).map(&:to_s))
|
|
71
|
+
out[key.to_s] = set unless set.empty?
|
|
72
|
+
end.freeze
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|