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
data/lib/rigor/configuration.rb
CHANGED
|
@@ -6,6 +6,10 @@ 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 "configuration_error"
|
|
10
|
+
require_relative "effects/entry_points"
|
|
11
|
+
require_relative "effects/label"
|
|
12
|
+
require_relative "effects/method_key"
|
|
9
13
|
|
|
10
14
|
module Rigor
|
|
11
15
|
class Configuration # rubocop:disable Metrics/ClassLength
|
|
@@ -22,6 +26,15 @@ module Rigor
|
|
|
22
26
|
# fixture paths still work; the discovery list is the canonical source.
|
|
23
27
|
DEFAULT_PATH = DISCOVERY_ORDER.first
|
|
24
28
|
|
|
29
|
+
# ADR-103 WD7 — where `rigor effects update` writes when `effects.snapshot.path:` is unset. Public
|
|
30
|
+
# because the schema pins its own `default` against it.
|
|
31
|
+
DEFAULT_EFFECTS_SNAPSHOT_PATH = ".rigor-effects.yml"
|
|
32
|
+
|
|
33
|
+
# ADR-103 WD15 — the {BleedingEdge::FEATURES} id that, when adopted, makes a loaded file with no
|
|
34
|
+
# `effects:` key behave as `effects: {}`. See {#coerce_effects}.
|
|
35
|
+
EFFECTS_ON_BY_DEFAULT_FEATURE_ID = "effects-on-by-default"
|
|
36
|
+
private_constant :EFFECTS_ON_BY_DEFAULT_FEATURE_ID
|
|
37
|
+
|
|
25
38
|
# Built-in exclusion patterns appended to `exclude:` so vendored dependencies, Bundler artefacts, and
|
|
26
39
|
# JavaScript node_modules are never analysed by accident when a directory glob expands. Users cannot
|
|
27
40
|
# disable these defaults; the trade-off is that analysing any of these paths is essentially never what
|
|
@@ -65,6 +78,24 @@ module Rigor
|
|
|
65
78
|
# lifted): the incremental session recomputes the table each run and diffs it against its snapshot,
|
|
66
79
|
# re-checking any callee whose seeds moved.
|
|
67
80
|
"parameter_inference" => false,
|
|
81
|
+
# ADR-103 WD13 — the effect-labels opt-in, and the ONLY thing that turns effect collection on
|
|
82
|
+
# besides running `rigor effects`. **Presence is the switch**: `effects: {}` — or the bare key, which
|
|
83
|
+
# YAML parses as nil — enables collection with every sub-key at its default, because an annotation in
|
|
84
|
+
# a project's RBS must never create a project-wide cost cliff (that case earns a `:info` residual
|
|
85
|
+
# instead, #384).
|
|
86
|
+
#
|
|
87
|
+
# The default is `false` rather than `nil` precisely because presence is the switch and
|
|
88
|
+
# `Configuration.load` merges these DEFAULTS UNDER the loaded file: a `nil` default would be
|
|
89
|
+
# indistinguishable from a user's bare `effects:` key and would turn collection on for every project
|
|
90
|
+
# in existence. `false` is also the explicit-disable form a `.rigor.yml` uses to override an upstream
|
|
91
|
+
# `.rigor.dist.yml`'s `effects:` block — the same shape `baseline:` uses.
|
|
92
|
+
#
|
|
93
|
+
# The sub-keys are declared in `schemas/rigor-config.schema.json`. `check` (#383,
|
|
94
|
+
# {#effects_check?}), `snapshot.{path,reach,gate}`, `tolerated` (#381) and the policy trio
|
|
95
|
+
# `labels` / `attribution` / `envelopes` (#385, {#coerce_effects_policy}) are read; `views` lands
|
|
96
|
+
# with the slice that implements it (#390). Reserving a sub-key's shape before a reader exists is
|
|
97
|
+
# the same discipline the schema applies to a sibling implementation's namespace (ADR-99).
|
|
98
|
+
"effects" => false,
|
|
68
99
|
"cache" => {
|
|
69
100
|
"path" => ".rigor/cache",
|
|
70
101
|
# LRU eviction cap in bytes (ADR-54 WD3). The least-recently-used entries are removed at the end of a
|
|
@@ -211,7 +242,27 @@ module Rigor
|
|
|
211
242
|
:dependencies, :parallel_workers,
|
|
212
243
|
:bundler_bundle_path, :bundler_auto_detect, :bundler_lockfile,
|
|
213
244
|
:rbs_collection_lockfile, :rbs_collection_auto_detect,
|
|
214
|
-
:pre_eval, :baseline_path
|
|
245
|
+
:pre_eval, :baseline_path, :effects,
|
|
246
|
+
:effects_snapshot_path, :effects_snapshot_reach, :effects_snapshot_gate, :effects_tolerated,
|
|
247
|
+
:effects_labels, :effects_attribution, :effects_envelopes
|
|
248
|
+
|
|
249
|
+
# ADR-103 WD13 — whether effect collection runs. True exactly when the loaded configuration carried an
|
|
250
|
+
# `effects:` block, whatever its body; `rigor effects` enables it for its own run by loading an
|
|
251
|
+
# implicit `effects: {}` instead of by consulting anything else. Nothing else — no annotation, no
|
|
252
|
+
# plugin, no severity profile — can turn collection on.
|
|
253
|
+
def effects_enabled?
|
|
254
|
+
!@effects.nil?
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# ADR-103 WD14 / #383 — whether author-declared effect envelopes (`%a{pure}`,
|
|
258
|
+
# `%a{rigor:v1:effect …}`) are checked against the collected summaries, surfacing
|
|
259
|
+
# `effect.envelope-exceeded`. Defaults to **true when the `effects:` block is present**: the block is
|
|
260
|
+
# the opt-in, and a project that asked for effects asked for its own declarations to hold. An explicit
|
|
261
|
+
# `check: false` disables the diagnostic and leaves collection — and therefore `rigor effects` and the
|
|
262
|
+
# snapshot — untouched. Always false without the block, so an annotation alone changes nothing.
|
|
263
|
+
def effects_check?
|
|
264
|
+
@effects_check
|
|
265
|
+
end
|
|
215
266
|
|
|
216
267
|
# Loads a configuration file.
|
|
217
268
|
#
|
|
@@ -226,13 +277,19 @@ module Rigor
|
|
|
226
277
|
# [PHPStan](https://phpstan.org/config-reference#paths).
|
|
227
278
|
def self.load(path = nil)
|
|
228
279
|
resolved = path || discover
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
280
|
+
if resolved.nil? || !File.exist?(resolved)
|
|
281
|
+
data = DEFAULTS
|
|
282
|
+
effects_key_present = false
|
|
283
|
+
else
|
|
284
|
+
# ADR-103 WD15 — captured from the RAW, pre-`DEFAULTS.merge` file (+ its `includes:` chain) because
|
|
285
|
+
# `DEFAULTS` itself carries an `"effects" => false` entry: once merged, "the file never wrote
|
|
286
|
+
# `effects:`" and "the file wrote `effects: false`" collapse to the same value and become
|
|
287
|
+
# indistinguishable to {#initialize}. This is the only place that distinction still exists.
|
|
288
|
+
raw = load_with_includes(resolved)
|
|
289
|
+
effects_key_present = raw.key?("effects")
|
|
290
|
+
data = DEFAULTS.merge(raw)
|
|
291
|
+
end
|
|
292
|
+
new(autowire_default_plugins(data), effects_key_present)
|
|
236
293
|
end
|
|
237
294
|
|
|
238
295
|
# ADR-93 WD2 — the one bundled plugin default-wired without a `plugins:` entry. `rigor-rbs-inline` is the
|
|
@@ -302,10 +359,10 @@ module Rigor
|
|
|
302
359
|
# {CLI::CheckCommand#load_check_configuration}).
|
|
303
360
|
def self.load_with_includes(path, visited: Set.new)
|
|
304
361
|
absolute = File.expand_path(path)
|
|
305
|
-
raise
|
|
362
|
+
raise ConfigurationError, "circular include: #{absolute}" if visited.include?(absolute)
|
|
306
363
|
|
|
307
|
-
raw =
|
|
308
|
-
raise
|
|
364
|
+
raw = read_yaml(absolute)
|
|
365
|
+
raise ConfigurationError, "config file must be a YAML mapping: #{absolute}" unless raw.is_a?(Hash)
|
|
309
366
|
|
|
310
367
|
base_dir = File.dirname(absolute)
|
|
311
368
|
includes = Array(raw.delete("includes") || [])
|
|
@@ -314,6 +371,17 @@ module Rigor
|
|
|
314
371
|
merge_includes(data, includes, base_dir, next_visited)
|
|
315
372
|
end
|
|
316
373
|
|
|
374
|
+
# #433's sibling: a typo in the file the user is about to be told to fix is a configuration mistake
|
|
375
|
+
# like any other, and escaped as a `Psych::SyntaxError` backtrace naming a file inside Ruby's stdlib.
|
|
376
|
+
# Psych's own `#message` embeds the path in a parenthesised prefix that reads badly after `rigor: `,
|
|
377
|
+
# so the position is re-rendered in the `path:line:column` form the rest of Rigor's output uses.
|
|
378
|
+
def self.read_yaml(absolute)
|
|
379
|
+
YAML.safe_load_file(absolute, aliases: false) || {}
|
|
380
|
+
rescue Psych::SyntaxError => e
|
|
381
|
+
detail = [e.problem, e.context].compact.join(" ")
|
|
382
|
+
raise ConfigurationError, "#{absolute}:#{e.line}:#{e.column}: not valid YAML: #{detail}"
|
|
383
|
+
end
|
|
384
|
+
|
|
317
385
|
def self.merge_includes(data, includes, base_dir, visited)
|
|
318
386
|
return data if includes.empty?
|
|
319
387
|
|
|
@@ -321,7 +389,7 @@ module Rigor
|
|
|
321
389
|
includes.each do |inc|
|
|
322
390
|
inc_path = File.expand_path(inc.to_s, base_dir)
|
|
323
391
|
unless File.exist?(inc_path)
|
|
324
|
-
raise
|
|
392
|
+
raise ConfigurationError, "include not found: #{inc.inspect} (referenced from #{base_dir})"
|
|
325
393
|
end
|
|
326
394
|
|
|
327
395
|
accumulated = deep_merge(accumulated, load_with_includes(inc_path, visited: visited))
|
|
@@ -394,7 +462,17 @@ module Rigor
|
|
|
394
462
|
:merge_value, :merge_dependencies_hash
|
|
395
463
|
|
|
396
464
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
397
|
-
|
|
465
|
+
#
|
|
466
|
+
# @param effects_key_present [Boolean] ADR-103 WD15 — whether the file this `data` came from carried an
|
|
467
|
+
# `effects:` key at all, independent of its value. Defaults to `data.key?("effects")`, which is the
|
|
468
|
+
# right answer for a caller that passes a raw (non-`DEFAULTS`-merged) hash directly — the common shape
|
|
469
|
+
# in specs — but is always `true` once `data` has been through `DEFAULTS.merge`, because `DEFAULTS`
|
|
470
|
+
# itself carries the key; {.load} therefore computes this from the pre-merge file and passes it
|
|
471
|
+
# explicitly. See {#coerce_effects}. **Positional, deliberately not a keyword**: a bare (unbraced)
|
|
472
|
+
# `"key" => value` hash literal — this class's usual call shape, all over the spec suite — is coerced
|
|
473
|
+
# into keyword arguments by Ruby whenever the method declares ANY keyword parameter, which would break
|
|
474
|
+
# every `Configuration.new("some_key" => value)` call site with an "unknown keyword" `ArgumentError`.
|
|
475
|
+
def initialize(data = DEFAULTS, effects_key_present = data.key?("effects"))
|
|
398
476
|
# Record before the per-key fetches below discard the evidence. Top level only, deliberately —
|
|
399
477
|
# see {ConfigAudit.unknown_key_warnings} for why a nested check cannot key on DEFAULTS.
|
|
400
478
|
@unknown_keys = (data.keys.map(&:to_s) - KNOWN_KEYS).sort.freeze
|
|
@@ -423,6 +501,18 @@ module Rigor
|
|
|
423
501
|
# ADR-67 WD6a — resolve to a strict Boolean so a truthy non-`true` value (e.g. a stray String) does not
|
|
424
502
|
# silently enable the gate; only the literal `true` activates the check-walk collector pre-pass.
|
|
425
503
|
@parameter_inference = data.fetch("parameter_inference", DEFAULTS.fetch("parameter_inference")) == true
|
|
504
|
+
# Resolved before `@effects` — ADR-103 WD15's "effects-on-by-default" reads {#bleeding_edge_active?},
|
|
505
|
+
# which reads `@bleeding_edge_active_ids`.
|
|
506
|
+
@bleeding_edge = coerce_bleeding_edge(
|
|
507
|
+
data.fetch("bleeding_edge", DEFAULTS.fetch("bleeding_edge"))
|
|
508
|
+
)
|
|
509
|
+
@bleeding_edge_severity_overrides = BleedingEdge.severity_overrides_for(@bleeding_edge)
|
|
510
|
+
@bleeding_edge_active_ids = BleedingEdge.active_ids_for(@bleeding_edge)
|
|
511
|
+
# ADR-103 WD13 — presence, not truthiness: `effects:` written with no body parses as nil and still
|
|
512
|
+
# means "on", so the key's presence in the loaded data is what {#effects_enabled?} reads.
|
|
513
|
+
@effects = coerce_effects(data, effects_key_present: effects_key_present)
|
|
514
|
+
coerce_effects_snapshot(@effects)
|
|
515
|
+
coerce_effects_policy(@effects)
|
|
426
516
|
@cache_path = cache.fetch("path").to_s
|
|
427
517
|
raw_max = cache.fetch("max_bytes")
|
|
428
518
|
@cache_max_bytes = raw_max.nil? ? nil : Integer(raw_max)
|
|
@@ -436,11 +526,6 @@ module Rigor
|
|
|
436
526
|
@severity_overrides = coerce_severity_overrides(
|
|
437
527
|
data.fetch("severity_overrides", DEFAULTS.fetch("severity_overrides"))
|
|
438
528
|
)
|
|
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
529
|
@dependencies = Dependencies.from_h(
|
|
445
530
|
data.fetch("dependencies", DEFAULTS.fetch("dependencies"))
|
|
446
531
|
)
|
|
@@ -576,8 +661,217 @@ module Rigor
|
|
|
576
661
|
copy.freeze
|
|
577
662
|
end
|
|
578
663
|
|
|
664
|
+
# ADR-103 WD14 — the ad-hoc opt-in `rigor effects` uses when the project's configuration carries no
|
|
665
|
+
# `effects:` block: a sibling Configuration with an implicit empty block, every other field shared.
|
|
666
|
+
# A configuration that already enables effects is returned unchanged, so a project's own settings
|
|
667
|
+
# always win over the implicit ones.
|
|
668
|
+
#
|
|
669
|
+
# Same `dup` + re-`freeze` shape as {#with_bleeding_edge}: every other ivar is the receiver's deeply
|
|
670
|
+
# frozen value, safe to share read-only, and the result stays `Ractor.shareable?` for the worker path.
|
|
671
|
+
def with_effects_enabled
|
|
672
|
+
return self if effects_enabled?
|
|
673
|
+
|
|
674
|
+
copy = dup
|
|
675
|
+
copy.instance_variable_set(:@effects, {}.freeze)
|
|
676
|
+
# The implicit block defaults like a written one. `rigor effects` emits no diagnostic either way,
|
|
677
|
+
# but the two configurations must not differ in a field a later reader could branch on.
|
|
678
|
+
copy.instance_variable_set(:@effects_check, true)
|
|
679
|
+
copy.freeze
|
|
680
|
+
end
|
|
681
|
+
|
|
579
682
|
private
|
|
580
683
|
|
|
684
|
+
# ADR-103 WD13 — the `effects:` block, or nil when the key is absent or explicitly `false`. A
|
|
685
|
+
# present-but-empty key (`effects:` alone, which YAML parses as nil) and `effects: {}` are the same
|
|
686
|
+
# thing: collection on, every sub-key defaulted. A non-Hash body is normalised to `{}` rather than
|
|
687
|
+
# rejected — the sub-keys have no reader yet, so there is nothing a malformed body could break, and
|
|
688
|
+
# tier 1 (the schema) is where its shape is answered.
|
|
689
|
+
#
|
|
690
|
+
# ADR-103 WD15 — a loaded file that carries no `effects:` key AT ALL (`effects_key_present == false`,
|
|
691
|
+
# so `value` can only be the `false` DEFAULTS fallback here, never a user's own explicit `false`) behaves
|
|
692
|
+
# as `effects: {}` when the "effects-on-by-default" bleeding-edge feature is adopted. An explicit
|
|
693
|
+
# `effects: false` — `effects_key_present == true` — always stays off regardless of the feature, and a
|
|
694
|
+
# written block always wins outright: the feature can only fill an *absence*.
|
|
695
|
+
def coerce_effects(data, effects_key_present:)
|
|
696
|
+
value = data.fetch("effects", false)
|
|
697
|
+
return {}.freeze if value == false && !effects_key_present && effects_forced_on_by_bleeding_edge?
|
|
698
|
+
return nil if value == false
|
|
699
|
+
|
|
700
|
+
(value.is_a?(Hash) ? value : {}).freeze
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# ADR-103 WD15 — guarded by {BleedingEdge.known_id?} rather than calling {#bleeding_edge_active?}
|
|
704
|
+
# directly: that predicate RAISES for an id neither queued nor graduated, which is correct for a
|
|
705
|
+
# contributor's typo against the registry in this same checkout, but a spec that stubs
|
|
706
|
+
# `BleedingEdge::FEATURES` down to an unrelated single feature (a common pattern for isolating the
|
|
707
|
+
# selector plumbing — see `spec/rigor/configuration_spec.rb`) must not have Configuration construction
|
|
708
|
+
# explode merely because the id this internal default-application reads is not the one under test.
|
|
709
|
+
def effects_forced_on_by_bleeding_edge?
|
|
710
|
+
BleedingEdge.known_id?(EFFECTS_ON_BY_DEFAULT_FEATURE_ID) &&
|
|
711
|
+
bleeding_edge_active?(EFFECTS_ON_BY_DEFAULT_FEATURE_ID)
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# ADR-103 WD7 / WD14 — the snapshot keys and the minimal `tolerated:` policy list (#381).
|
|
715
|
+
#
|
|
716
|
+
# Every key resolves to its default when `effects:` is absent, so a consumer reads one uniform surface
|
|
717
|
+
# and never has to ask whether the block was there. The values are read by `rigor effects update` /
|
|
718
|
+
# `check` / `diff` / `explain` and by nothing on `rigor check`'s path.
|
|
719
|
+
#
|
|
720
|
+
# Validation is **tier 2** (`ArgumentError`, the run stops), because each of these is a value the
|
|
721
|
+
# snapshot commands cannot proceed on: an unknown `gate:` would silently pick a semantics, an unknown
|
|
722
|
+
# preset name would silently match no file, and a malformed label would silently tolerate nothing.
|
|
723
|
+
# A label that is well-formed but unknown to the *registry* is deliberately NOT rejected here — that is
|
|
724
|
+
# `effect.unknown-label`'s job (#384), and it fails open.
|
|
725
|
+
def coerce_effects_snapshot(effects)
|
|
726
|
+
@effects_check = coerce_effects_check?(effects)
|
|
727
|
+
snapshot = effects&.fetch("snapshot", nil)
|
|
728
|
+
snapshot = {} unless snapshot.is_a?(Hash)
|
|
729
|
+
@effects_snapshot_path = (snapshot.fetch("path", nil) || DEFAULT_EFFECTS_SNAPSHOT_PATH).to_s.freeze
|
|
730
|
+
@effects_snapshot_reach = coerce_effects_reach(snapshot.fetch("reach", nil))
|
|
731
|
+
@effects_snapshot_gate = coerce_effects_gate(snapshot.fetch("gate", nil))
|
|
732
|
+
@effects_tolerated = coerce_effects_tolerated(effects&.fetch("tolerated", nil))
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
# `effects.check:` — true by default under a present block, and never true without one. Only an
|
|
736
|
+
# explicit `false` turns it off; any other value is normalised to true rather than rejected, because a
|
|
737
|
+
# switch whose only two readings are "check" and "do not check" has nothing a tier-2 error would add.
|
|
738
|
+
def coerce_effects_check?(effects)
|
|
739
|
+
return false if effects.nil?
|
|
740
|
+
|
|
741
|
+
effects.fetch("check", true) != false
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
VALID_EFFECTS_GATES = %i[symmetric additions].freeze
|
|
745
|
+
private_constant :VALID_EFFECTS_GATES
|
|
746
|
+
|
|
747
|
+
def coerce_effects_gate(value)
|
|
748
|
+
return :symmetric if value.nil?
|
|
749
|
+
|
|
750
|
+
gate = value.to_s.to_sym
|
|
751
|
+
unless VALID_EFFECTS_GATES.include?(gate)
|
|
752
|
+
raise ConfigurationError,
|
|
753
|
+
"effects.snapshot.gate must be one of #{VALID_EFFECTS_GATES.inspect}, got #{value.inspect}"
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
gate
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
# Entry-point globs and preset names, kept as written.
|
|
760
|
+
#
|
|
761
|
+
# **Shape only.** A preset is *named by a plugin* (#387; ADR-103 WD14) and plugins load from this very
|
|
762
|
+
# configuration, so at this point no preset is registered yet and asking `EntryPoints.known?` would
|
|
763
|
+
# reject `reach: [rails]` for every project that uses one. The existence check therefore lives where
|
|
764
|
+
# the snapshot expands `reach:` ({Effects::Snapshot.expand_reach}), which runs after plugin load and
|
|
765
|
+
# raises there — the same error, at the first moment it can be right.
|
|
766
|
+
def coerce_effects_reach(value)
|
|
767
|
+
entries = Array(value).map(&:to_s)
|
|
768
|
+
entries.each do |entry|
|
|
769
|
+
next if Effects::EntryPoints.glob?(entry) || Effects::EntryPoints.name?(entry)
|
|
770
|
+
|
|
771
|
+
raise ConfigurationError,
|
|
772
|
+
"effects.snapshot.reach entry is neither a file glob nor a well-formed entry-point preset " \
|
|
773
|
+
"name: #{entry.inspect} (a preset name is #{Effects::EntryPoints::NAME_PATTERN.inspect}; " \
|
|
774
|
+
"anything carrying a path or glob character is treated as a file glob instead)"
|
|
775
|
+
end
|
|
776
|
+
entries.uniq.freeze
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
# Shape validation only: the label grammar of `docs/type-specification/effect-labels.md`.
|
|
780
|
+
def coerce_effects_tolerated(value)
|
|
781
|
+
labels = Array(value).map(&:to_s)
|
|
782
|
+
labels.each do |label|
|
|
783
|
+
raise ConfigurationError, "effects.tolerated is not a well-formed effect label: #{label.inspect}" unless
|
|
784
|
+
Effects::Label.valid?(label)
|
|
785
|
+
end
|
|
786
|
+
labels.uniq.sort.freeze
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
# ADR-103 WD5 / WD6 / #385 — the three policy keys: the project's own vocabulary (`labels:`), its
|
|
790
|
+
# attribution table for code Rigor does not analyse (`attribution:`), and its envelopes by convention
|
|
791
|
+
# (`envelopes:`).
|
|
792
|
+
#
|
|
793
|
+
# Validation is **tier 2** for shape and tier 2 only. A label whose SPELLING is malformed, an entry
|
|
794
|
+
# naming neither `match:` nor `namespace:` (or both), and an attribution key that is not a method key
|
|
795
|
+
# each stop the run, because none of them has a reading the loader could pick. A label that is
|
|
796
|
+
# well-formed but unknown to the *registry* is deliberately fine here: it fails open (⊤ for an
|
|
797
|
+
# envelope, an unregistered meaning for an attribution) and surfaces as `effect.unknown-label`.
|
|
798
|
+
def coerce_effects_policy(effects)
|
|
799
|
+
@effects_labels = coerce_effects_labels(effects&.fetch("labels", nil))
|
|
800
|
+
@effects_attribution = coerce_effects_attribution(effects&.fetch("attribution", nil))
|
|
801
|
+
@effects_envelopes = coerce_effects_envelopes(effects&.fetch("envelopes", nil))
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
NO_ATTRIBUTION = {}.freeze
|
|
805
|
+
private_constant :NO_ATTRIBUTION
|
|
806
|
+
|
|
807
|
+
def coerce_effects_labels(value)
|
|
808
|
+
labels = Array(value).map(&:to_s)
|
|
809
|
+
labels.each do |label|
|
|
810
|
+
raise ConfigurationError, "effects.labels is not a well-formed effect label: #{label.inspect}" unless
|
|
811
|
+
Effects::Label.valid?(label)
|
|
812
|
+
end
|
|
813
|
+
labels.uniq.sort.freeze
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# `{ "Net::HTTP.get" => ["io.net.http"] }` — a method key exactly as the symbol tables spell one
|
|
817
|
+
# (`Owner#instance`, `Owner.singleton`), mapped to the labels a call to it contributes.
|
|
818
|
+
def coerce_effects_attribution(value)
|
|
819
|
+
return NO_ATTRIBUTION unless value.is_a?(Hash)
|
|
820
|
+
|
|
821
|
+
value.each_with_object({}) do |(key, labels), out|
|
|
822
|
+
name = key.to_s
|
|
823
|
+
unless Effects::MethodKey.valid?(name)
|
|
824
|
+
raise ConfigurationError,
|
|
825
|
+
"effects.attribution key is not a method key (`Owner#method` / `Owner.method`): #{name.inspect}"
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
out[name] = coerce_effect_label_list(Array(labels), "effects.attribution[#{name.inspect}]")
|
|
829
|
+
end.freeze
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# `[{ match: | namespace:, effect: [...] }]`. Exactly one selector per entry: an entry naming both
|
|
833
|
+
# would need a precedence rule between two selectors of the same entry, and one naming neither selects
|
|
834
|
+
# every class in the project, which is never what an author meant to write.
|
|
835
|
+
def coerce_effects_envelopes(value)
|
|
836
|
+
Array(value).each_with_index.map { |entry, index| coerce_effects_envelope(entry, index) }.freeze
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
def coerce_effects_envelope(entry, index)
|
|
840
|
+
where = "effects.envelopes[#{index}]"
|
|
841
|
+
raise ConfigurationError, "#{where} is not a mapping: #{entry.inspect}" unless entry.is_a?(Hash)
|
|
842
|
+
|
|
843
|
+
match = coerce_effects_envelope_selector(entry["match"], "#{where}.match")
|
|
844
|
+
namespace = coerce_effects_envelope_selector(entry["namespace"], "#{where}.namespace")
|
|
845
|
+
if match.nil? == namespace.nil?
|
|
846
|
+
raise ConfigurationError, "#{where} must name exactly one of `match:` (a path glob) or `namespace:` " \
|
|
847
|
+
"(a constant glob), got #{match.nil? ? 'neither' : 'both'}"
|
|
848
|
+
end
|
|
849
|
+
unless entry.key?("effect")
|
|
850
|
+
raise ConfigurationError, "#{where} has no `effect:` bound (write `effect: []` for the empty envelope)"
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
{
|
|
854
|
+
"match" => match, "namespace" => namespace,
|
|
855
|
+
"effect" => coerce_effect_label_list(Array(entry["effect"]), "#{where}.effect")
|
|
856
|
+
}.freeze
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
def coerce_effects_envelope_selector(value, where)
|
|
860
|
+
return nil if value.nil?
|
|
861
|
+
|
|
862
|
+
selector = value.to_s
|
|
863
|
+
raise ConfigurationError, "#{where} is empty" if selector.strip.empty?
|
|
864
|
+
|
|
865
|
+
selector.freeze
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
def coerce_effect_label_list(labels, where)
|
|
869
|
+
labels.map(&:to_s).each do |label|
|
|
870
|
+
raise ConfigurationError, "#{where} is not a well-formed effect label: #{label.inspect}" unless
|
|
871
|
+
Effects::Label.valid?(label)
|
|
872
|
+
end.uniq.sort.freeze
|
|
873
|
+
end
|
|
874
|
+
|
|
581
875
|
# ADR-17 slice 4 — `pre_eval:` glob expansion. Each entry is accepted as either a literal path (slice 1
|
|
582
876
|
# contract) OR a `File.fnmatch?`-shaped glob pattern (`lib/core_ext/**/*.rb`). Glob meta characters (`*`,
|
|
583
877
|
# `?`, `[`) trigger `Dir.glob` expansion; the resulting file list is folded into the `pre_eval:` set with
|
|
@@ -604,7 +898,7 @@ module Rigor
|
|
|
604
898
|
when Hash
|
|
605
899
|
entry.to_h { |k, v| [k.to_s, v] }.freeze
|
|
606
900
|
else
|
|
607
|
-
raise
|
|
901
|
+
raise ConfigurationError,
|
|
608
902
|
"plugin configuration entry must be a String or Hash, got #{entry.inspect}"
|
|
609
903
|
end
|
|
610
904
|
end
|
|
@@ -621,7 +915,7 @@ module Rigor
|
|
|
621
915
|
def coerce_target_ruby(value)
|
|
622
916
|
s = value.to_s
|
|
623
917
|
unless s.match?(TARGET_RUBY_FORMAT)
|
|
624
|
-
raise
|
|
918
|
+
raise ConfigurationError,
|
|
625
919
|
"target_ruby must be a version (e.g. \"3.4\", \"4.0\", \"3.4.0\") or \"latest\", got #{value.inspect}"
|
|
626
920
|
end
|
|
627
921
|
|
|
@@ -642,11 +936,11 @@ module Rigor
|
|
|
642
936
|
# disabling parallelism.
|
|
643
937
|
def coerce_parallel_workers(value)
|
|
644
938
|
integer = Integer(value)
|
|
645
|
-
raise
|
|
939
|
+
raise ConfigurationError, "parallel.workers must be >= 0, got #{value.inspect}" if integer.negative?
|
|
646
940
|
|
|
647
941
|
integer
|
|
648
942
|
rescue TypeError, ArgumentError => e
|
|
649
|
-
raise
|
|
943
|
+
raise ConfigurationError, "parallel.workers must be a non-negative Integer, got #{value.inspect} (#{e.message})"
|
|
650
944
|
end
|
|
651
945
|
|
|
652
946
|
# ADR-22 WD2 (b) — `baseline: <path>` activates the file; `baseline: false` is the explicit-disable form
|
|
@@ -673,7 +967,7 @@ module Rigor
|
|
|
673
967
|
def coerce_network_policy(value)
|
|
674
968
|
sym = value.to_sym
|
|
675
969
|
unless VALID_NETWORK_POLICIES.include?(sym)
|
|
676
|
-
raise
|
|
970
|
+
raise ConfigurationError,
|
|
677
971
|
"plugins_io.network must be one of #{VALID_NETWORK_POLICIES.inspect}, got #{value.inspect}"
|
|
678
972
|
end
|
|
679
973
|
|
|
@@ -685,7 +979,7 @@ module Rigor
|
|
|
685
979
|
def coerce_severity_profile(value)
|
|
686
980
|
sym = value.to_sym
|
|
687
981
|
unless SeverityProfile::VALID_PROFILES.include?(sym)
|
|
688
|
-
raise
|
|
982
|
+
raise ConfigurationError,
|
|
689
983
|
"severity_profile must be one of " \
|
|
690
984
|
"#{SeverityProfile::VALID_PROFILES.inspect}, got #{value.inspect}"
|
|
691
985
|
end
|
|
@@ -698,7 +992,7 @@ module Rigor
|
|
|
698
992
|
# {SeverityProfile::VALID_SEVERITIES} symbols (`:error` / `:warning` / `:info` / `:off`). Unknown
|
|
699
993
|
# severities raise; unknown rule ids are silently kept (the override is inert until the rule lands).
|
|
700
994
|
def coerce_severity_overrides(value)
|
|
701
|
-
raise
|
|
995
|
+
raise ConfigurationError, "severity_overrides must be a Hash, got #{value.inspect}" unless value.is_a?(Hash)
|
|
702
996
|
|
|
703
997
|
value.to_h do |k, v|
|
|
704
998
|
# YAML 1.1 parses bare `off`/`on`/`no`/`yes`/`true`/`false` as booleans, so a user who wrote `off` (a
|
|
@@ -706,7 +1000,7 @@ module Rigor
|
|
|
706
1000
|
# `to_sym` blows up with a backtrace.
|
|
707
1001
|
unless v.is_a?(String) || v.is_a?(Symbol)
|
|
708
1002
|
hint = v == false ? %( — did you mean the string "off"?) : ""
|
|
709
|
-
raise
|
|
1003
|
+
raise ConfigurationError,
|
|
710
1004
|
"severity_overrides[#{k.inspect}] is #{v.inspect}, a YAML boolean#{hint} " \
|
|
711
1005
|
"Bare off/on/no/yes/true/false are parsed as booleans; quote the severity " \
|
|
712
1006
|
"(e.g. \"off\")."
|
|
@@ -714,7 +1008,7 @@ module Rigor
|
|
|
714
1008
|
|
|
715
1009
|
sym = v.to_sym
|
|
716
1010
|
unless SeverityProfile::VALID_SEVERITIES.include?(sym)
|
|
717
|
-
raise
|
|
1011
|
+
raise ConfigurationError,
|
|
718
1012
|
"severity_overrides[#{k.inspect}] must be one of " \
|
|
719
1013
|
"#{SeverityProfile::VALID_SEVERITIES.inspect}, got #{v.inspect}"
|
|
720
1014
|
end
|
|
@@ -734,7 +1028,7 @@ module Rigor
|
|
|
734
1028
|
when Array then { "mode" => "list", "ids" => freeze_ids(value) }
|
|
735
1029
|
when Hash then coerce_bleeding_edge_hash(value)
|
|
736
1030
|
else
|
|
737
|
-
raise
|
|
1031
|
+
raise ConfigurationError,
|
|
738
1032
|
"bleeding_edge must be true, false, a list of feature ids, " \
|
|
739
1033
|
"or { all: true, except: [...] }, got #{value.inspect}"
|
|
740
1034
|
end.freeze
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
# A mistake in the project's configuration — a value `Configuration` cannot proceed on (the tier-2
|
|
5
|
+
# failures `docs/internal-spec/config.md` enumerates), or a `.rigor.yml` key the loader accepted whose
|
|
6
|
+
# meaning only resolves later, like an `effects.snapshot.reach:` preset name.
|
|
7
|
+
#
|
|
8
|
+
# It exists so the CLI can tell **the user got the file wrong** apart from **Rigor got itself wrong**.
|
|
9
|
+
# Both used to reach the terminal as an uncaught `ArgumentError` with a thirty-frame backtrace naming a
|
|
10
|
+
# file inside `lib/rigor/`, which reads as a crash: the reader's first move is to file a bug, not to
|
|
11
|
+
# fix the key the message already named (#433). {CLI#run} rescues this class and renders it as a single
|
|
12
|
+
# `rigor:` line.
|
|
13
|
+
#
|
|
14
|
+
# It stays an `ArgumentError` subclass deliberately. `Configuration` raised `ArgumentError` from every
|
|
15
|
+
# coercion since the beginning, that is the documented tier-2 contract, and callers outside the CLI —
|
|
16
|
+
# the language server, embedders, the suite — rescue it by that name. Narrowing the class is a
|
|
17
|
+
# presentation change, not a contract change.
|
|
18
|
+
class ConfigurationError < ArgumentError
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -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
|