rigortype 0.3.4 → 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/effects/core.yml +18 -1
- data/data/effects/registry.yml +31 -3
- data/docs/manual/02-cli-reference.md +60 -13
- data/docs/manual/03-configuration.md +20 -3
- data/docs/manual/04-diagnostics.md +3 -2
- data/docs/manual/11-ci.md +37 -0
- data/docs/manual/12-caching.md +39 -0
- data/docs/manual/16-rbs-extended-annotations.md +15 -2
- data/docs/manual/19-effect-labels.md +671 -0
- data/docs/manual/README.md +5 -0
- data/docs/manual/ci-templates/README.md +9 -0
- data/lib/rigor/analysis/rule_catalog.rb +10 -3
- data/lib/rigor/analysis/run_cache_probe.rb +69 -1
- data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +1 -1
- data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
- data/lib/rigor/analysis/runner.rb +12 -3
- data/lib/rigor/analysis/worker_session.rb +3 -1
- data/lib/rigor/cli/effects_command.rb +123 -9
- data/lib/rigor/cli/effects_diff_renderer.rb +5 -1
- data/lib/rigor/cli/effects_renderer.rb +41 -3
- data/lib/rigor/cli/effects_report.rb +116 -6
- data/lib/rigor/cli/effects_snapshot_command.rb +23 -4
- data/lib/rigor/cli.rb +12 -1
- data/lib/rigor/configuration.rb +37 -25
- data/lib/rigor/configuration_error.rb +20 -0
- data/lib/rigor/effects/collector.rb +38 -1
- data/lib/rigor/effects/entry_points.rb +47 -1
- data/lib/rigor/effects/file_collection.rb +18 -4
- data/lib/rigor/effects/framework_units.rb +68 -13
- data/lib/rigor/effects/inline_anchor.rb +134 -0
- data/lib/rigor/effects/plugin_facts.rb +62 -13
- data/lib/rigor/effects/propagator.rb +79 -19
- data/lib/rigor/effects/registry.rb +10 -3
- data/lib/rigor/effects/scanner.rb +21 -9
- data/lib/rigor/effects/signature_sources.rb +16 -0
- data/lib/rigor/effects/snapshot.rb +21 -5
- data/lib/rigor/effects/taint_cause.rb +1 -0
- data/lib/rigor/effects/unit_scan.rb +87 -12
- data/lib/rigor/plugin/base.rb +4 -0
- data/lib/rigor/plugin/box.rb +18 -2
- data/lib/rigor/plugin/effect_ancestry.rb +80 -0
- data/lib/rigor/plugin/manifest.rb +34 -10
- data/lib/rigor/plugin/registry.rb +10 -3
- data/lib/rigor/rbs_extended.rb +22 -2
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -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.rb +13 -1
- metadata +6 -1
|
@@ -16,17 +16,53 @@ module Rigor
|
|
|
16
16
|
class EffectsRenderer
|
|
17
17
|
include Renderable
|
|
18
18
|
|
|
19
|
-
def initialize(out:)
|
|
19
|
+
def initialize(out:, why: false)
|
|
20
20
|
@out = out
|
|
21
|
+
@why = why
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
private
|
|
24
25
|
|
|
26
|
+
# The reason block is **collapsed to a count** by default (#434). It was 86.5 % of the bytes of a
|
|
27
|
+
# 31,191-line Redmine run, and it answers a question a reader asks about one row after reading many
|
|
28
|
+
# — so `--why` expands it, and the count is what stays on the line that made them curious.
|
|
25
29
|
def render_text(report)
|
|
26
30
|
report.rows.each do |row|
|
|
27
|
-
@out.puts("#{row.key}: [#{row.effects.join(', ')}]#{declared(row)}#{
|
|
31
|
+
@out.puts("#{row.key}: [#{row.effects.join(', ')}]#{declared(row)}#{hedge(row)}")
|
|
32
|
+
next unless @why
|
|
33
|
+
|
|
28
34
|
row.causes.each { |cause, detail| @out.puts(" #{cause}#{" (#{detail})" if detail}") }
|
|
35
|
+
row.attribution.each { |origin, labels| @out.puts(" #{origin} → [#{labels.join(', ')}]") }
|
|
29
36
|
end
|
|
37
|
+
render_footer(report.totals)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def hedge(row)
|
|
41
|
+
return "" if row.exhaustive?
|
|
42
|
+
return " …?" if @why || row.causes.empty?
|
|
43
|
+
|
|
44
|
+
" …? (#{row.causes.length} #{row.causes.length == 1 ? 'reason' : 'reasons'}, --why)"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The footer a 31,191-line report never had, and the reason it counts the two lanes apart: a
|
|
48
|
+
# declared label can never fail a build (ADR-103 § WD17), so a reader who sees one total cannot tell
|
|
49
|
+
# which half of the report is a policy surface and which half is a record to review the diff of.
|
|
50
|
+
def render_footer(totals)
|
|
51
|
+
return if totals.nil?
|
|
52
|
+
|
|
53
|
+
@out.puts("──")
|
|
54
|
+
@out.puts("#{totals.printed} of #{totals.units} units printed#{omitted(totals)}")
|
|
55
|
+
@out.puts("#{totals.proven} carry a proven label · #{totals.declared} carry a declared (≤) one " \
|
|
56
|
+
"· #{totals.exhaustive} are exhaustive")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Two ways a row can be missing, counted apart because `--full` answers only one of them.
|
|
60
|
+
def omitted(totals)
|
|
61
|
+
parts = []
|
|
62
|
+
parts << "#{totals.omitted} omitted (--full)" if totals.omitted.positive?
|
|
63
|
+
parts << "#{totals.unselected} not selected" if totals.unselected.positive?
|
|
64
|
+
parts << "#{totals.truncated} cut by --limit" if totals.truncated.positive?
|
|
65
|
+
parts.empty? ? "" : "; #{parts.join(', ')}"
|
|
30
66
|
end
|
|
31
67
|
|
|
32
68
|
# `≤` is the lane's spelling everywhere in the model — an upper bound, not an observation — so the
|
|
@@ -43,10 +79,12 @@ module Rigor
|
|
|
43
79
|
"declared" => row.declared,
|
|
44
80
|
"exhaustive" => row.exhaustive?,
|
|
45
81
|
"causes" => row.causes.map { |cause, detail| [cause, detail] },
|
|
46
|
-
"direct" => row.direct
|
|
82
|
+
"direct" => row.direct,
|
|
83
|
+
"attribution" => row.attribution
|
|
47
84
|
}]
|
|
48
85
|
end
|
|
49
86
|
}
|
|
87
|
+
payload["totals"] = report.totals.to_h.transform_keys(&:to_s) if report.totals
|
|
50
88
|
@out.puts(JSON.pretty_generate(payload))
|
|
51
89
|
end
|
|
52
90
|
end
|
|
@@ -7,7 +7,12 @@ module Rigor
|
|
|
7
7
|
#
|
|
8
8
|
# Rows are sorted by key and every collection inside one is sorted, so two runs over the same tree
|
|
9
9
|
# print byte-identical output whether analysis ran sequentially or across the fork pool.
|
|
10
|
-
class EffectsReport < Data.define(:rows, :full)
|
|
10
|
+
class EffectsReport < Data.define(:rows, :full, :totals)
|
|
11
|
+
# `totals:` defaults so a caller that builds a report by hand keeps working.
|
|
12
|
+
def initialize(totals: nil, **rest)
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
|
|
11
16
|
# One method's line in the report.
|
|
12
17
|
#
|
|
13
18
|
# `effects` is the transitive proven lane — this method's labels joined with every project method it
|
|
@@ -20,18 +25,116 @@ module Rigor
|
|
|
20
25
|
# is printed apart from `effects` and never folded into it, because the two answer different
|
|
21
26
|
# questions: one is proven, the other is asserted. A declared label the proven lane already admits
|
|
22
27
|
# is dropped here, where output is rendered; the table keeps both lanes raw.
|
|
23
|
-
class Row < Data.define(:key, :effects, :declared, :exhaustive, :causes, :direct)
|
|
28
|
+
class Row < Data.define(:key, :effects, :declared, :exhaustive, :causes, :direct, :attribution)
|
|
29
|
+
# `attribution:` defaults so a caller that builds a row by hand keeps working.
|
|
30
|
+
def initialize(attribution: {}, **rest)
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
|
|
24
34
|
def exhaustive?
|
|
25
35
|
exhaustive
|
|
26
36
|
end
|
|
37
|
+
|
|
38
|
+
# The reading `--pure` selects and the default report omits: nothing proven beyond what every
|
|
39
|
+
# envelope tolerates, nothing claimed, and no "possibly more".
|
|
40
|
+
def pure?
|
|
41
|
+
exhaustive && declared.empty? && (effects - TRIVIAL).empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# `mutate.local` is mutation of objects the frame allocated and never let out, which every
|
|
45
|
+
# envelope tolerates — so a method proving only it is what `%a{pure}` means here.
|
|
46
|
+
TRIVIAL = ["mutate.local"].freeze
|
|
47
|
+
private_constant :TRIVIAL
|
|
48
|
+
|
|
49
|
+
def carries_label?(labels)
|
|
50
|
+
labels.any? { |label| (effects + declared).any? { |own| own == label || own.start_with?("#{label}.") } }
|
|
51
|
+
end
|
|
27
52
|
end
|
|
28
53
|
|
|
54
|
+
# The counts the footer prints and the JSON payload carries (#434). The two lanes are counted
|
|
55
|
+
# **separately** and deliberately: a declared label can never fail a build ([ADR-103](../adr/103-effect-labels.md)
|
|
56
|
+
# § WD17), and on a Rails application it is most of the mass — 709 `io.db.read` plus 644
|
|
57
|
+
# `io.db.write` on Redmine's 4,234 rows against zero of either proven. A single total tells a reader
|
|
58
|
+
# how big the report is; the split tells them which half is a policy surface and which half is a
|
|
59
|
+
# record whose diff they should be reviewing instead.
|
|
60
|
+
Totals = Data.define(:units, :printed, :omitted, :unselected, :proven, :declared, :exhaustive,
|
|
61
|
+
:truncated)
|
|
62
|
+
|
|
29
63
|
# Builds a report from an effect table. `full:` keeps the rows the report otherwise omits — an
|
|
30
64
|
# exhaustive method proving nothing beyond `mutate.local`, which is the reading of `%a{pure}`.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
65
|
+
#
|
|
66
|
+
# `scope:` selects which units are **printed** and never which are analysed (#439). A path argument
|
|
67
|
+
# used to narrow the analysis, and a summary is transitive over whatever was analysed, so the
|
|
68
|
+
# narrowed run answered `[] …?` for a method the whole-project run answered four labels for — with
|
|
69
|
+
# nothing distinguishing that from a method which genuinely does nothing. `sources:` is
|
|
70
|
+
# `Runner#effect_sources`, `{ "Class#m" => [path, …] }`, which is how a key is traced back to the
|
|
71
|
+
# file it was written in.
|
|
72
|
+
def self.build(table, full: false, sources: nil, scope: [], label: [], pure: false, limit: nil)
|
|
73
|
+
roots = normalize_scope(scope)
|
|
74
|
+
in_scope = table.filter_map do |entry|
|
|
75
|
+
row_for(entry) if roots.empty? || in_scope?(entry.key, sources, roots)
|
|
76
|
+
end
|
|
77
|
+
selected = in_scope.select { |row| keep?(row, full: full, label: label, pure: pure) }
|
|
78
|
+
new(rows: (limit ? selected.first(limit) : selected).freeze, full: full,
|
|
79
|
+
totals: totals_for(table, in_scope, selected, limit, query: pure || !label.empty?))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# What the default report drops, and why each is a separate question:
|
|
83
|
+
#
|
|
84
|
+
# - **`full:`** keeps a row the omission rule would drop. Two shapes qualify: a method proving
|
|
85
|
+
# nothing beyond `mutate.local` and claiming nothing (the reading of `%a{pure}`), and a row with
|
|
86
|
+
# no label in **either** lane, which is 35–38 % of a real application's rows and says literally
|
|
87
|
+
# nothing — it exists only to record that something was unresolved, which the footer counts.
|
|
88
|
+
# - **`pure:`** is the complement of the first: exactly the rows the default omits for being
|
|
89
|
+
# provably harmless, which is the set worth annotating and which nothing could ask for (#457).
|
|
90
|
+
# - **`label:`** is the question chapter 19 opens with. It matches **either** lane, because "which
|
|
91
|
+
# controllers reach the network" is a question about the code and not about which lane knows it;
|
|
92
|
+
# the row's own rendering keeps the two apart.
|
|
93
|
+
def self.keep?(row, full:, label:, pure:)
|
|
94
|
+
return row.pure? if pure
|
|
95
|
+
return row.carries_label?(label) unless label.empty?
|
|
96
|
+
return true if full
|
|
97
|
+
|
|
98
|
+
!row.pure? && !(row.effects.empty? && row.declared.empty?)
|
|
99
|
+
end
|
|
100
|
+
private_class_method :keep?
|
|
101
|
+
|
|
102
|
+
# Two ways a row can be missing, and only one of them `--full` answers: the **omission rule** drops
|
|
103
|
+
# a row that says nothing, and a **filter** — a path, `--label`, `--pure` — drops one that did not
|
|
104
|
+
# match. Telling a `--label io.net` reader that 4,678 more are behind `--full` would be false, so
|
|
105
|
+
# the two are counted apart.
|
|
106
|
+
def self.totals_for(table, in_scope, selected, limit, query:)
|
|
107
|
+
omitted = query ? 0 : in_scope.length - selected.length
|
|
108
|
+
Totals.new(
|
|
109
|
+
units: table.size, printed: limit ? [selected.length, limit].min : selected.length,
|
|
110
|
+
omitted: omitted, unselected: table.size - selected.length - omitted,
|
|
111
|
+
proven: selected.count { |row| !row.effects.empty? },
|
|
112
|
+
declared: selected.count { |row| !row.declared.empty? },
|
|
113
|
+
exhaustive: selected.count(&:exhaustive?),
|
|
114
|
+
truncated: limit ? [selected.length - limit, 0].max : 0
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
private_class_method :totals_for
|
|
118
|
+
|
|
119
|
+
# A path argument may name a file or a directory, and either may be written relative or absolute —
|
|
120
|
+
# so both sides are expanded and a directory matches by prefix. Deliberately not the `reach:` glob
|
|
121
|
+
# syntax: this is the argument a shell just tab-completed, and `rigor effects app/models` meaning
|
|
122
|
+
# "that directory" is the only reading a reader would guess.
|
|
123
|
+
def self.normalize_scope(scope)
|
|
124
|
+
Array(scope).map { |path| File.expand_path(path.to_s.chomp("/")) }.freeze
|
|
125
|
+
end
|
|
126
|
+
private_class_method :normalize_scope
|
|
127
|
+
|
|
128
|
+
def self.in_scope?(key, sources, roots)
|
|
129
|
+
paths = sources && sources[key]
|
|
130
|
+
return false if paths.nil? || paths.empty?
|
|
131
|
+
|
|
132
|
+
paths.any? do |path|
|
|
133
|
+
absolute = File.expand_path(path)
|
|
134
|
+
roots.any? { |root| absolute == root || absolute.start_with?("#{root}/") }
|
|
135
|
+
end
|
|
34
136
|
end
|
|
137
|
+
private_class_method :in_scope?
|
|
35
138
|
|
|
36
139
|
def self.row_for(entry)
|
|
37
140
|
Row.new(
|
|
@@ -40,7 +143,14 @@ module Rigor
|
|
|
40
143
|
declared: entry.rendered_declared.to_a,
|
|
41
144
|
exhaustive: entry.exhaustive?,
|
|
42
145
|
causes: entry.causes,
|
|
43
|
-
direct: entry.direct.bundles.to_h { |origin, labels| [origin.to_s, labels.to_a] }.freeze
|
|
146
|
+
direct: entry.direct.bundles.to_h { |origin, labels| [origin.to_s, labels.to_a] }.freeze,
|
|
147
|
+
# The declared lane's provenance (#434). A *discharging* plugin row leaves no cause line by
|
|
148
|
+
# design — WD6: a row the engine bundles is trusted, so it does not taint — which is why
|
|
149
|
+
# `plugin-attribution` appeared zero times in a whole Redmine run while 1,320 rows carried a
|
|
150
|
+
# `≤` clause. The origins are where the answer actually lives, and this is the direct half; a
|
|
151
|
+
# label that arrived transitively is `rigor effects explain`'s question.
|
|
152
|
+
attribution: entry.direct.declared_bundles.to_h { |origin, labels| [origin.to_s, labels.to_a] }
|
|
153
|
+
.freeze
|
|
44
154
|
)
|
|
45
155
|
end
|
|
46
156
|
private_class_method :row_for
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "did_you_mean"
|
|
3
4
|
require "optionparser"
|
|
4
5
|
|
|
5
6
|
require_relative "../analysis/runner"
|
|
6
7
|
require_relative "../cache/store"
|
|
7
8
|
require_relative "../configuration"
|
|
8
9
|
require_relative "../effects/discharge"
|
|
10
|
+
require_relative "../effects/entry_points"
|
|
9
11
|
require_relative "../effects/path_finder"
|
|
10
12
|
require_relative "../effects/snapshot"
|
|
11
13
|
require_relative "../effects/snapshot_diff"
|
|
@@ -79,8 +81,14 @@ module Rigor
|
|
|
79
81
|
File.write(path, snapshot.to_yaml)
|
|
80
82
|
@err.puts("rigor: wrote #{path} (#{snapshot.methods.size} method(s), #{snapshot.reach.size} reach entr" \
|
|
81
83
|
"#{snapshot.reach.size == 1 ? 'y' : 'ies'})")
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
# #436 — the note names what to write, not only what is missing: the same per-project preset
|
|
85
|
+
# enumeration the unregistered-preset error uses, so the two can never disagree about which
|
|
86
|
+
# names this project's plugin set makes available.
|
|
87
|
+
if configuration.effects_snapshot_reach.empty?
|
|
88
|
+
@err.puts("rigor: note — `effects.snapshot.reach:` is empty, so the snapshot records `methods:` " \
|
|
89
|
+
"only — the direct half. Name your entry points to record what they cause: " \
|
|
90
|
+
"#{Effects::EntryPoints.availability}. The written file carries the same hint.")
|
|
91
|
+
end
|
|
84
92
|
0
|
|
85
93
|
end
|
|
86
94
|
|
|
@@ -126,20 +134,31 @@ module Rigor
|
|
|
126
134
|
|
|
127
135
|
def explain(diff, options)
|
|
128
136
|
rows = options.fetch(:symbol) ? rows_for_symbol(options.fetch(:symbol)) : rows_for_changes(diff)
|
|
137
|
+
return CLI::EXIT_USAGE if rows == :unknown
|
|
138
|
+
|
|
129
139
|
EffectsExplainRenderer.new(out: @out).render(rows, format: options.fetch(:format))
|
|
130
140
|
0
|
|
131
141
|
end
|
|
132
142
|
|
|
143
|
+
# A misspelled `--symbol` used to print `Nothing to explain.` and exit 0, which is exactly what a
|
|
144
|
+
# method with no effects prints — so a typo and a real answer were indistinguishable, and a script
|
|
145
|
+
# could not tell them apart at all (#435). It is a usage error now, with the nearest key offered:
|
|
146
|
+
# the key set is right there, and a method key is long enough to get wrong.
|
|
133
147
|
def rows_for_symbol(symbol)
|
|
134
148
|
entry = @table[symbol]
|
|
135
149
|
if entry.nil?
|
|
136
|
-
@err.puts("rigor: no effect unit named #{symbol}")
|
|
137
|
-
return
|
|
150
|
+
@err.puts("rigor: no effect unit named #{symbol}#{suggestion(symbol)}")
|
|
151
|
+
return :unknown
|
|
138
152
|
end
|
|
139
153
|
|
|
140
154
|
reach_rows(symbol, entry.proven.to_a) + method_rows(symbol, entry.direct.proven.to_a)
|
|
141
155
|
end
|
|
142
156
|
|
|
157
|
+
def suggestion(symbol)
|
|
158
|
+
nearest = ::DidYouMean::SpellChecker.new(dictionary: @table.keys).correct(symbol).first
|
|
159
|
+
nearest ? " — did you mean #{nearest}?" : ""
|
|
160
|
+
end
|
|
161
|
+
|
|
143
162
|
# Every label a change introduced, explained once: a `reach:` change gets its shortest edge path, a
|
|
144
163
|
# `methods:` change gets the origin in the method's own body.
|
|
145
164
|
def rows_for_changes(diff)
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -84,6 +84,17 @@ module Rigor
|
|
|
84
84
|
rescue OptionParser::ParseError => e
|
|
85
85
|
@err.puts(e.message)
|
|
86
86
|
EXIT_USAGE
|
|
87
|
+
rescue ConfigurationError => e
|
|
88
|
+
# #433 — a mistake in `.rigor.yml` is the same kind of event as a bad flag, and belongs in the same
|
|
89
|
+
# shape: one `rigor:` line naming the key, and the conventional usage exit code. It used to escape
|
|
90
|
+
# as an uncaught exception with a ~30-frame backtrace naming a file inside `lib/rigor/`, which reads
|
|
91
|
+
# as a crash even though the message it carried said exactly which key to fix.
|
|
92
|
+
#
|
|
93
|
+
# Caught here rather than per command because every command loads a configuration, and the ones
|
|
94
|
+
# that resolve a key later (`effects update` expanding `snapshot.reach:` once the plugins that
|
|
95
|
+
# register presets have loaded) would each need their own rescue at their own point.
|
|
96
|
+
@err.puts("rigor: #{e.message}")
|
|
97
|
+
EXIT_USAGE
|
|
87
98
|
end
|
|
88
99
|
|
|
89
100
|
private
|
|
@@ -160,7 +171,7 @@ module Rigor
|
|
|
160
171
|
# - paths: directories scanned by `rigor check` and
|
|
161
172
|
# `rigor type-scan` when no path is given.
|
|
162
173
|
# - plugins: opt-in list of plugin gem names to load.
|
|
163
|
-
# See https://github.com/rigortype/rigor/tree/
|
|
174
|
+
# See https://github.com/rigortype/rigor/tree/master/plugins
|
|
164
175
|
# for production plugins (rigor-activerecord, rigor-sorbet, …).
|
|
165
176
|
# - disable: list of `rigor check` rule identifiers to
|
|
166
177
|
# silence project-wide. The shipped rules are
|
data/lib/rigor/configuration.rb
CHANGED
|
@@ -6,6 +6,7 @@ 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"
|
|
9
10
|
require_relative "effects/entry_points"
|
|
10
11
|
require_relative "effects/label"
|
|
11
12
|
require_relative "effects/method_key"
|
|
@@ -358,10 +359,10 @@ module Rigor
|
|
|
358
359
|
# {CLI::CheckCommand#load_check_configuration}).
|
|
359
360
|
def self.load_with_includes(path, visited: Set.new)
|
|
360
361
|
absolute = File.expand_path(path)
|
|
361
|
-
raise
|
|
362
|
+
raise ConfigurationError, "circular include: #{absolute}" if visited.include?(absolute)
|
|
362
363
|
|
|
363
|
-
raw =
|
|
364
|
-
raise
|
|
364
|
+
raw = read_yaml(absolute)
|
|
365
|
+
raise ConfigurationError, "config file must be a YAML mapping: #{absolute}" unless raw.is_a?(Hash)
|
|
365
366
|
|
|
366
367
|
base_dir = File.dirname(absolute)
|
|
367
368
|
includes = Array(raw.delete("includes") || [])
|
|
@@ -370,6 +371,17 @@ module Rigor
|
|
|
370
371
|
merge_includes(data, includes, base_dir, next_visited)
|
|
371
372
|
end
|
|
372
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
|
+
|
|
373
385
|
def self.merge_includes(data, includes, base_dir, visited)
|
|
374
386
|
return data if includes.empty?
|
|
375
387
|
|
|
@@ -377,7 +389,7 @@ module Rigor
|
|
|
377
389
|
includes.each do |inc|
|
|
378
390
|
inc_path = File.expand_path(inc.to_s, base_dir)
|
|
379
391
|
unless File.exist?(inc_path)
|
|
380
|
-
raise
|
|
392
|
+
raise ConfigurationError, "include not found: #{inc.inspect} (referenced from #{base_dir})"
|
|
381
393
|
end
|
|
382
394
|
|
|
383
395
|
accumulated = deep_merge(accumulated, load_with_includes(inc_path, visited: visited))
|
|
@@ -737,7 +749,7 @@ module Rigor
|
|
|
737
749
|
|
|
738
750
|
gate = value.to_s.to_sym
|
|
739
751
|
unless VALID_EFFECTS_GATES.include?(gate)
|
|
740
|
-
raise
|
|
752
|
+
raise ConfigurationError,
|
|
741
753
|
"effects.snapshot.gate must be one of #{VALID_EFFECTS_GATES.inspect}, got #{value.inspect}"
|
|
742
754
|
end
|
|
743
755
|
|
|
@@ -756,7 +768,7 @@ module Rigor
|
|
|
756
768
|
entries.each do |entry|
|
|
757
769
|
next if Effects::EntryPoints.glob?(entry) || Effects::EntryPoints.name?(entry)
|
|
758
770
|
|
|
759
|
-
raise
|
|
771
|
+
raise ConfigurationError,
|
|
760
772
|
"effects.snapshot.reach entry is neither a file glob nor a well-formed entry-point preset " \
|
|
761
773
|
"name: #{entry.inspect} (a preset name is #{Effects::EntryPoints::NAME_PATTERN.inspect}; " \
|
|
762
774
|
"anything carrying a path or glob character is treated as a file glob instead)"
|
|
@@ -768,7 +780,7 @@ module Rigor
|
|
|
768
780
|
def coerce_effects_tolerated(value)
|
|
769
781
|
labels = Array(value).map(&:to_s)
|
|
770
782
|
labels.each do |label|
|
|
771
|
-
raise
|
|
783
|
+
raise ConfigurationError, "effects.tolerated is not a well-formed effect label: #{label.inspect}" unless
|
|
772
784
|
Effects::Label.valid?(label)
|
|
773
785
|
end
|
|
774
786
|
labels.uniq.sort.freeze
|
|
@@ -795,7 +807,7 @@ module Rigor
|
|
|
795
807
|
def coerce_effects_labels(value)
|
|
796
808
|
labels = Array(value).map(&:to_s)
|
|
797
809
|
labels.each do |label|
|
|
798
|
-
raise
|
|
810
|
+
raise ConfigurationError, "effects.labels is not a well-formed effect label: #{label.inspect}" unless
|
|
799
811
|
Effects::Label.valid?(label)
|
|
800
812
|
end
|
|
801
813
|
labels.uniq.sort.freeze
|
|
@@ -809,7 +821,7 @@ module Rigor
|
|
|
809
821
|
value.each_with_object({}) do |(key, labels), out|
|
|
810
822
|
name = key.to_s
|
|
811
823
|
unless Effects::MethodKey.valid?(name)
|
|
812
|
-
raise
|
|
824
|
+
raise ConfigurationError,
|
|
813
825
|
"effects.attribution key is not a method key (`Owner#method` / `Owner.method`): #{name.inspect}"
|
|
814
826
|
end
|
|
815
827
|
|
|
@@ -826,16 +838,16 @@ module Rigor
|
|
|
826
838
|
|
|
827
839
|
def coerce_effects_envelope(entry, index)
|
|
828
840
|
where = "effects.envelopes[#{index}]"
|
|
829
|
-
raise
|
|
841
|
+
raise ConfigurationError, "#{where} is not a mapping: #{entry.inspect}" unless entry.is_a?(Hash)
|
|
830
842
|
|
|
831
843
|
match = coerce_effects_envelope_selector(entry["match"], "#{where}.match")
|
|
832
844
|
namespace = coerce_effects_envelope_selector(entry["namespace"], "#{where}.namespace")
|
|
833
845
|
if match.nil? == namespace.nil?
|
|
834
|
-
raise
|
|
835
|
-
|
|
846
|
+
raise ConfigurationError, "#{where} must name exactly one of `match:` (a path glob) or `namespace:` " \
|
|
847
|
+
"(a constant glob), got #{match.nil? ? 'neither' : 'both'}"
|
|
836
848
|
end
|
|
837
849
|
unless entry.key?("effect")
|
|
838
|
-
raise
|
|
850
|
+
raise ConfigurationError, "#{where} has no `effect:` bound (write `effect: []` for the empty envelope)"
|
|
839
851
|
end
|
|
840
852
|
|
|
841
853
|
{
|
|
@@ -848,14 +860,14 @@ module Rigor
|
|
|
848
860
|
return nil if value.nil?
|
|
849
861
|
|
|
850
862
|
selector = value.to_s
|
|
851
|
-
raise
|
|
863
|
+
raise ConfigurationError, "#{where} is empty" if selector.strip.empty?
|
|
852
864
|
|
|
853
865
|
selector.freeze
|
|
854
866
|
end
|
|
855
867
|
|
|
856
868
|
def coerce_effect_label_list(labels, where)
|
|
857
869
|
labels.map(&:to_s).each do |label|
|
|
858
|
-
raise
|
|
870
|
+
raise ConfigurationError, "#{where} is not a well-formed effect label: #{label.inspect}" unless
|
|
859
871
|
Effects::Label.valid?(label)
|
|
860
872
|
end.uniq.sort.freeze
|
|
861
873
|
end
|
|
@@ -886,7 +898,7 @@ module Rigor
|
|
|
886
898
|
when Hash
|
|
887
899
|
entry.to_h { |k, v| [k.to_s, v] }.freeze
|
|
888
900
|
else
|
|
889
|
-
raise
|
|
901
|
+
raise ConfigurationError,
|
|
890
902
|
"plugin configuration entry must be a String or Hash, got #{entry.inspect}"
|
|
891
903
|
end
|
|
892
904
|
end
|
|
@@ -903,7 +915,7 @@ module Rigor
|
|
|
903
915
|
def coerce_target_ruby(value)
|
|
904
916
|
s = value.to_s
|
|
905
917
|
unless s.match?(TARGET_RUBY_FORMAT)
|
|
906
|
-
raise
|
|
918
|
+
raise ConfigurationError,
|
|
907
919
|
"target_ruby must be a version (e.g. \"3.4\", \"4.0\", \"3.4.0\") or \"latest\", got #{value.inspect}"
|
|
908
920
|
end
|
|
909
921
|
|
|
@@ -924,11 +936,11 @@ module Rigor
|
|
|
924
936
|
# disabling parallelism.
|
|
925
937
|
def coerce_parallel_workers(value)
|
|
926
938
|
integer = Integer(value)
|
|
927
|
-
raise
|
|
939
|
+
raise ConfigurationError, "parallel.workers must be >= 0, got #{value.inspect}" if integer.negative?
|
|
928
940
|
|
|
929
941
|
integer
|
|
930
942
|
rescue TypeError, ArgumentError => e
|
|
931
|
-
raise
|
|
943
|
+
raise ConfigurationError, "parallel.workers must be a non-negative Integer, got #{value.inspect} (#{e.message})"
|
|
932
944
|
end
|
|
933
945
|
|
|
934
946
|
# ADR-22 WD2 (b) — `baseline: <path>` activates the file; `baseline: false` is the explicit-disable form
|
|
@@ -955,7 +967,7 @@ module Rigor
|
|
|
955
967
|
def coerce_network_policy(value)
|
|
956
968
|
sym = value.to_sym
|
|
957
969
|
unless VALID_NETWORK_POLICIES.include?(sym)
|
|
958
|
-
raise
|
|
970
|
+
raise ConfigurationError,
|
|
959
971
|
"plugins_io.network must be one of #{VALID_NETWORK_POLICIES.inspect}, got #{value.inspect}"
|
|
960
972
|
end
|
|
961
973
|
|
|
@@ -967,7 +979,7 @@ module Rigor
|
|
|
967
979
|
def coerce_severity_profile(value)
|
|
968
980
|
sym = value.to_sym
|
|
969
981
|
unless SeverityProfile::VALID_PROFILES.include?(sym)
|
|
970
|
-
raise
|
|
982
|
+
raise ConfigurationError,
|
|
971
983
|
"severity_profile must be one of " \
|
|
972
984
|
"#{SeverityProfile::VALID_PROFILES.inspect}, got #{value.inspect}"
|
|
973
985
|
end
|
|
@@ -980,7 +992,7 @@ module Rigor
|
|
|
980
992
|
# {SeverityProfile::VALID_SEVERITIES} symbols (`:error` / `:warning` / `:info` / `:off`). Unknown
|
|
981
993
|
# severities raise; unknown rule ids are silently kept (the override is inert until the rule lands).
|
|
982
994
|
def coerce_severity_overrides(value)
|
|
983
|
-
raise
|
|
995
|
+
raise ConfigurationError, "severity_overrides must be a Hash, got #{value.inspect}" unless value.is_a?(Hash)
|
|
984
996
|
|
|
985
997
|
value.to_h do |k, v|
|
|
986
998
|
# YAML 1.1 parses bare `off`/`on`/`no`/`yes`/`true`/`false` as booleans, so a user who wrote `off` (a
|
|
@@ -988,7 +1000,7 @@ module Rigor
|
|
|
988
1000
|
# `to_sym` blows up with a backtrace.
|
|
989
1001
|
unless v.is_a?(String) || v.is_a?(Symbol)
|
|
990
1002
|
hint = v == false ? %( — did you mean the string "off"?) : ""
|
|
991
|
-
raise
|
|
1003
|
+
raise ConfigurationError,
|
|
992
1004
|
"severity_overrides[#{k.inspect}] is #{v.inspect}, a YAML boolean#{hint} " \
|
|
993
1005
|
"Bare off/on/no/yes/true/false are parsed as booleans; quote the severity " \
|
|
994
1006
|
"(e.g. \"off\")."
|
|
@@ -996,7 +1008,7 @@ module Rigor
|
|
|
996
1008
|
|
|
997
1009
|
sym = v.to_sym
|
|
998
1010
|
unless SeverityProfile::VALID_SEVERITIES.include?(sym)
|
|
999
|
-
raise
|
|
1011
|
+
raise ConfigurationError,
|
|
1000
1012
|
"severity_overrides[#{k.inspect}] must be one of " \
|
|
1001
1013
|
"#{SeverityProfile::VALID_SEVERITIES.inspect}, got #{v.inspect}"
|
|
1002
1014
|
end
|
|
@@ -1016,7 +1028,7 @@ module Rigor
|
|
|
1016
1028
|
when Array then { "mode" => "list", "ids" => freeze_ids(value) }
|
|
1017
1029
|
when Hash then coerce_bleeding_edge_hash(value)
|
|
1018
1030
|
else
|
|
1019
|
-
raise
|
|
1031
|
+
raise ConfigurationError,
|
|
1020
1032
|
"bleeding_edge must be true, false, a list of feature ids, " \
|
|
1021
1033
|
"or { all: true, except: [...] }, got #{value.inspect}"
|
|
1022
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
|
|
@@ -128,7 +128,7 @@ module Rigor
|
|
|
128
128
|
# {Accumulator#record} is what stops it paying for the `Data` and the origin lookup below.
|
|
129
129
|
return if accumulator.recorded?(node)
|
|
130
130
|
|
|
131
|
-
dynamic =
|
|
131
|
+
dynamic = dynamic_receiver?(receiver)
|
|
132
132
|
class_name, kind = descriptor_for(receiver)
|
|
133
133
|
accumulator.record(
|
|
134
134
|
node,
|
|
@@ -160,9 +160,46 @@ module Rigor
|
|
|
160
160
|
when Type::HashShape then ["Hash", :instance]
|
|
161
161
|
when Type::Constant then [receiver.value.class.name, :instance]
|
|
162
162
|
when Type::Dynamic then descriptor_for(receiver.static_facet)
|
|
163
|
+
when Type::Union then union_descriptor(receiver)
|
|
163
164
|
end
|
|
164
165
|
end
|
|
165
166
|
|
|
167
|
+
# Whether the typer's verdict on this receiver leaves its class a guess. A bare `Dynamic` is the
|
|
168
|
+
# original case; a union carrying a `Dynamic` arm is the same knowledge in a different shape, and
|
|
169
|
+
# before #455 it was the shape that said nothing — no class, no edge, and no taint, so the summary
|
|
170
|
+
# read *exhaustive* while an arm of its receiver was admittedly unknown.
|
|
171
|
+
def dynamic_receiver?(receiver)
|
|
172
|
+
return true if receiver.is_a?(Type::Dynamic)
|
|
173
|
+
|
|
174
|
+
receiver.is_a?(Type::Union) && receiver.members.any?(Type::Dynamic)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# A union projects to a class exactly when its non-nil arms all project to the SAME one (#455).
|
|
178
|
+
#
|
|
179
|
+
# `T?` is the case that pays, and it is not a corner: ADR-58 contributes a declaration-sourced
|
|
180
|
+
# `nil` to every instance variable not written in `initialize`, so **every cross-method ivar read
|
|
181
|
+
# is a union** — `@group.save` reads `Group | nil` where the same `Group.new.save` two lines up
|
|
182
|
+
# reads `Group`. Without this arm the receiver had no class, so the plugin row never matched, the
|
|
183
|
+
# edge was dropped, and the site contributed nothing *while the summary still read exhaustive*.
|
|
184
|
+
# Whether the nil arm means the call happens at all is a question for `possible-nil-receiver`; it
|
|
185
|
+
# says nothing about what the call does when it does happen, which is the only question here.
|
|
186
|
+
#
|
|
187
|
+
# Arms that disagree (`File | StringIO`) still project to nothing. Answering with either one would
|
|
188
|
+
# state an effect no single execution need perform, and answering with both is a shape the record
|
|
189
|
+
# has no room for — one call site carries one receiver class.
|
|
190
|
+
def union_descriptor(receiver)
|
|
191
|
+
descriptors = receiver.members.reject { |member| nil_member?(member) }.map { |member| descriptor_for(member) }
|
|
192
|
+
first = descriptors.first
|
|
193
|
+
return nil if first.nil?
|
|
194
|
+
|
|
195
|
+
descriptors.all? { |descriptor| descriptor == first } ? first : nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def nil_member?(member)
|
|
199
|
+
(member.is_a?(Type::Constant) && member.value.nil?) ||
|
|
200
|
+
(member.is_a?(Type::Nominal) && member.class_name == "NilClass")
|
|
201
|
+
end
|
|
202
|
+
|
|
166
203
|
# Fail-soft (WD13): the scan raising drops this file's summaries and never reaches `rigor check`.
|
|
167
204
|
def build(accumulator)
|
|
168
205
|
return FileCollection.empty(accumulator.path) if accumulator.root.nil?
|