rigortype 0.3.4 → 0.3.6
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 +5 -5
- data/data/effects/core.yml +18 -1
- data/data/effects/registry.yml +31 -3
- data/data/gem_overlay/activesupport/core_ext.rbs +33 -0
- data/docs/handbook/02-everyday-types.md +1 -1
- data/docs/handbook/04-tuples-and-shapes.md +1 -1
- data/docs/handbook/08-understanding-errors.md +1 -1
- data/docs/handbook/09-plugins.md +2 -2
- data/docs/handbook/10-sorbet.md +1 -1
- data/docs/handbook/README.md +2 -2
- data/docs/handbook/appendix-go.md +1 -1
- data/docs/handbook/appendix-java-csharp.md +2 -2
- data/docs/handbook/appendix-mypy.md +1 -1
- data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
- data/docs/handbook/appendix-rust.md +1 -1
- data/docs/handbook/appendix-type-theory.md +2 -2
- data/docs/handbook/appendix-typescript.md +4 -4
- data/docs/manual/02-cli-reference.md +73 -22
- data/docs/manual/03-configuration.md +19 -2
- data/docs/manual/04-diagnostics.md +15 -2
- data/docs/manual/08-skills.md +1 -1
- data/docs/manual/11-ci.md +38 -1
- data/docs/manual/12-caching.md +40 -1
- data/docs/manual/15-type-protection-coverage.md +1 -1
- data/docs/manual/16-rbs-extended-annotations.md +15 -2
- data/docs/manual/18-removing-dead-code.md +13 -4
- data/docs/manual/19-effect-labels.md +678 -0
- data/docs/manual/README.md +7 -2
- data/docs/manual/ci-templates/README.md +9 -0
- data/docs/manual/plugins/rigor-actionmailer.md +4 -4
- data/docs/manual/plugins/rigor-activejob.md +3 -3
- data/docs/manual/plugins/rigor-activerecord.md +4 -4
- data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
- data/docs/manual/plugins/rigor-rspec.md +6 -2
- data/lib/rigor/analysis/diagnostic.rb +17 -6
- data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
- data/lib/rigor/analysis/reachability/graph.rb +30 -11
- data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
- data/lib/rigor/analysis/reachability/scan.rb +20 -4
- data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
- data/lib/rigor/analysis/rule_catalog.rb +10 -3
- data/lib/rigor/analysis/run_cache_key.rb +12 -0
- 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 +15 -9
- 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 +163 -31
- data/lib/rigor/analysis/worker_session.rb +3 -1
- data/lib/rigor/cache/file_digest.rb +20 -2
- data/lib/rigor/cli/check_command.rb +67 -48
- data/lib/rigor/cli/coverage_command.rb +5 -6
- data/lib/rigor/cli/doc_links.rb +100 -0
- data/lib/rigor/cli/docs_command.rb +32 -2
- data/lib/rigor/cli/effects_command.rb +149 -10
- data/lib/rigor/cli/effects_diff_renderer.rb +88 -14
- data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
- 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 +75 -11
- data/lib/rigor/cli/unused_command.rb +65 -16
- data/lib/rigor/cli.rb +33 -9
- 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/definition_lines.rb +100 -0
- data/lib/rigor/effects/entry_points.rb +47 -1
- data/lib/rigor/effects/envelope_check.rb +18 -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/liskov_check.rb +17 -8
- 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 +29 -2
- data/lib/rigor/effects/snapshot.rb +74 -26
- data/lib/rigor/effects/snapshot_diff.rb +26 -3
- data/lib/rigor/effects/taint_cause.rb +1 -0
- data/lib/rigor/effects/unit_scan.rb +87 -12
- data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
- 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/envelope_scanner.rb +9 -0
- 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
- data/skills/rigor-ci-setup/SKILL.md +2 -2
- data/skills/rigor-editor-setup/SKILL.md +2 -2
- data/skills/rigor-mcp-setup/SKILL.md +2 -2
- data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
- data/skills/rigor-plugin-review/SKILL.md +3 -3
- metadata +10 -1
|
@@ -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,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "did_you_mean"
|
|
3
4
|
require "optionparser"
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
# ADR-104 — `analysis/runner` is required lazily (in `#analyze`): a warm snapshot verb served by the
|
|
7
|
+
# probe must reach its answer without `rigor/inference` in `$LOADED_FEATURES`.
|
|
8
|
+
require_relative "../analysis/effects_cache_probe"
|
|
6
9
|
require_relative "../cache/store"
|
|
7
10
|
require_relative "../configuration"
|
|
8
11
|
require_relative "../effects/discharge"
|
|
12
|
+
require_relative "../effects/entry_points"
|
|
9
13
|
require_relative "../effects/path_finder"
|
|
10
14
|
require_relative "../effects/snapshot"
|
|
11
15
|
require_relative "../effects/snapshot_diff"
|
|
@@ -79,8 +83,14 @@ module Rigor
|
|
|
79
83
|
File.write(path, snapshot.to_yaml)
|
|
80
84
|
@err.puts("rigor: wrote #{path} (#{snapshot.methods.size} method(s), #{snapshot.reach.size} reach entr" \
|
|
81
85
|
"#{snapshot.reach.size == 1 ? 'y' : 'ies'})")
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
# #436 — the note names what to write, not only what is missing: the same per-project preset
|
|
87
|
+
# enumeration the unregistered-preset error uses, so the two can never disagree about which
|
|
88
|
+
# names this project's plugin set makes available.
|
|
89
|
+
if configuration.effects_snapshot_reach.empty?
|
|
90
|
+
@err.puts("rigor: note — `effects.snapshot.reach:` is empty, so the snapshot records `methods:` " \
|
|
91
|
+
"only — the direct half. Name your entry points to record what they cause: " \
|
|
92
|
+
"#{Effects::EntryPoints.availability}. The written file carries the same hint.")
|
|
93
|
+
end
|
|
84
94
|
0
|
|
85
95
|
end
|
|
86
96
|
|
|
@@ -103,7 +113,8 @@ module Rigor
|
|
|
103
113
|
)
|
|
104
114
|
return explain(diff, options) if @verb == "explain"
|
|
105
115
|
|
|
106
|
-
EffectsDiffRenderer.new(out: @out, path: path
|
|
116
|
+
EffectsDiffRenderer.new(out: @out, path: path, sources: @sources)
|
|
117
|
+
.render(diff, format: options.fetch(:format))
|
|
107
118
|
@verb == "check" && diff.drift? ? 1 : 0
|
|
108
119
|
end
|
|
109
120
|
|
|
@@ -126,24 +137,38 @@ module Rigor
|
|
|
126
137
|
|
|
127
138
|
def explain(diff, options)
|
|
128
139
|
rows = options.fetch(:symbol) ? rows_for_symbol(options.fetch(:symbol)) : rows_for_changes(diff)
|
|
140
|
+
return CLI::EXIT_USAGE if rows == :unknown
|
|
141
|
+
|
|
129
142
|
EffectsExplainRenderer.new(out: @out).render(rows, format: options.fetch(:format))
|
|
130
143
|
0
|
|
131
144
|
end
|
|
132
145
|
|
|
146
|
+
# A misspelled `--symbol` used to print `Nothing to explain.` and exit 0, which is exactly what a
|
|
147
|
+
# method with no effects prints — so a typo and a real answer were indistinguishable, and a script
|
|
148
|
+
# could not tell them apart at all (#435). It is a usage error now, with the nearest key offered:
|
|
149
|
+
# the key set is right there, and a method key is long enough to get wrong.
|
|
133
150
|
def rows_for_symbol(symbol)
|
|
134
151
|
entry = @table[symbol]
|
|
135
152
|
if entry.nil?
|
|
136
|
-
@err.puts("rigor: no effect unit named #{symbol}")
|
|
137
|
-
return
|
|
153
|
+
@err.puts("rigor: no effect unit named #{symbol}#{suggestion(symbol)}")
|
|
154
|
+
return :unknown
|
|
138
155
|
end
|
|
139
156
|
|
|
140
157
|
reach_rows(symbol, entry.proven.to_a) + method_rows(symbol, entry.direct.proven.to_a)
|
|
141
158
|
end
|
|
142
159
|
|
|
160
|
+
def suggestion(symbol)
|
|
161
|
+
nearest = ::DidYouMean::SpellChecker.new(dictionary: @table.keys).correct(symbol).first
|
|
162
|
+
nearest ? " — did you mean #{nearest}?" : ""
|
|
163
|
+
end
|
|
164
|
+
|
|
143
165
|
# Every label a change introduced, explained once: a `reach:` change gets its shortest edge path, a
|
|
144
166
|
# `methods:` change gets the origin in the method's own body.
|
|
145
167
|
def rows_for_changes(diff)
|
|
146
168
|
diff.events.flat_map do |event|
|
|
169
|
+
next exhaustiveness_rows(event) if
|
|
170
|
+
event.category == Effects::SnapshotDiff::EXHAUSTIVE_LOST
|
|
171
|
+
|
|
147
172
|
labels = labels_of(event)
|
|
148
173
|
next [] if labels.empty?
|
|
149
174
|
|
|
@@ -159,13 +184,29 @@ module Rigor
|
|
|
159
184
|
entry ? entry.proven.to_a : []
|
|
160
185
|
end
|
|
161
186
|
|
|
187
|
+
# `exhaustive → not` is the drift row a reader is least equipped to interpret, and it was the one
|
|
188
|
+
# row `explain` could not expand (#435): it carries no label, so {#labels_of} answered with nothing
|
|
189
|
+
# and the event produced no explanation at all. What explains it is the taint causes — the calls
|
|
190
|
+
# the analyzer could not follow — which is the detail the snapshot stopped recording per row
|
|
191
|
+
# (#434). The two halves meet here: the record keeps the stable count, and this names them.
|
|
192
|
+
def exhaustiveness_rows(event)
|
|
193
|
+
entry = @table[event.symbol]
|
|
194
|
+
return [] if entry.nil?
|
|
195
|
+
|
|
196
|
+
causes = Effects::Snapshot.render_causes(entry.causes)
|
|
197
|
+
return [] if causes.empty?
|
|
198
|
+
|
|
199
|
+
[EffectsExplainRenderer::Row.new(table: event.table, symbol: event.symbol, label: nil,
|
|
200
|
+
path: [].freeze, origin: nil, causes: causes)]
|
|
201
|
+
end
|
|
202
|
+
|
|
162
203
|
def reach_rows(symbol, labels)
|
|
163
204
|
labels.filter_map do |label|
|
|
164
205
|
path = Effects::PathFinder.shortest(@table, symbol: symbol, label: label)
|
|
165
206
|
next if path.nil?
|
|
166
207
|
|
|
167
208
|
EffectsExplainRenderer::Row.new(table: "reach", symbol: symbol, label: label,
|
|
168
|
-
path: path.to_a, origin: path.origin)
|
|
209
|
+
path: path.to_a, origin: path.origin, causes: [].freeze)
|
|
169
210
|
end
|
|
170
211
|
end
|
|
171
212
|
|
|
@@ -178,7 +219,7 @@ module Rigor
|
|
|
178
219
|
next if origin.nil?
|
|
179
220
|
|
|
180
221
|
EffectsExplainRenderer::Row.new(table: "methods", symbol: symbol, label: label,
|
|
181
|
-
path: [].freeze, origin: origin.to_s)
|
|
222
|
+
path: [].freeze, origin: origin.to_s, causes: [].freeze)
|
|
182
223
|
end
|
|
183
224
|
end
|
|
184
225
|
|
|
@@ -188,7 +229,20 @@ module Rigor
|
|
|
188
229
|
# the ADR-45 whole-run result cache like `rigor check` — the diagnostics entry plus the #382 effects
|
|
189
230
|
# sidecar keyed beside it, so `rigor effects check` after `rigor check` under a configured `effects:`
|
|
190
231
|
# block is a warm hit plus the fixpoint.
|
|
232
|
+
# ADR-104 — a snapshot is built from the table, the sources and the vocabulary, all three of which
|
|
233
|
+
# the #482 summary entry (plus the plugins' own declarations) carries, so a warm run takes the
|
|
234
|
+
# probe and never loads the engine. A decline is the runner path below, unchanged.
|
|
191
235
|
def build_snapshot(configuration, full:)
|
|
236
|
+
served = Analysis::EffectsCacheProbe.new(
|
|
237
|
+
configuration: configuration, cache_root: configuration.cache_path
|
|
238
|
+
).serve(configuration.paths)
|
|
239
|
+
return snapshot_from(configuration, full, served) if served
|
|
240
|
+
|
|
241
|
+
snapshot_from(configuration, full, analyze(configuration))
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def analyze(configuration)
|
|
245
|
+
require_relative "../analysis/runner"
|
|
192
246
|
runner = Analysis::Runner.new(
|
|
193
247
|
configuration: configuration,
|
|
194
248
|
cache_store: Cache::Store.new(root: configuration.cache_path),
|
|
@@ -196,14 +250,24 @@ module Rigor
|
|
|
196
250
|
workers: 0
|
|
197
251
|
)
|
|
198
252
|
runner.run(configuration.paths)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
table: @table, configuration: configuration, sources: runner.effect_sources, full: full,
|
|
253
|
+
Analysis::EffectsCacheProbe::Served.new(
|
|
254
|
+
table: runner.effect_table, sources: runner.effect_sources, plugin_facts: nil,
|
|
202
255
|
registry: Effects::Registry.for_configuration(configuration,
|
|
203
256
|
plugin_facts: runner.effect_plugin_facts)
|
|
204
257
|
)
|
|
205
258
|
end
|
|
206
259
|
|
|
260
|
+
# WD3 — one build, whichever lane produced the inputs, so a served snapshot and an analysed one
|
|
261
|
+
# cannot drift in how they are assembled.
|
|
262
|
+
def snapshot_from(configuration, full, served)
|
|
263
|
+
@table = served.table
|
|
264
|
+
@sources = served.sources
|
|
265
|
+
Effects::Snapshot.build(
|
|
266
|
+
table: @table, configuration: configuration, sources: served.sources, full: full,
|
|
267
|
+
registry: served.registry
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
|
|
207
271
|
# ---- options ------------------------------------------------
|
|
208
272
|
|
|
209
273
|
def parse_options
|
|
@@ -6,10 +6,12 @@ require "json"
|
|
|
6
6
|
require_relative "../configuration"
|
|
7
7
|
require_relative "../analysis/path_expansion"
|
|
8
8
|
require_relative "../analysis/reachability/scan"
|
|
9
|
+
require_relative "../analysis/reachability/scan_cache"
|
|
9
10
|
require_relative "../analysis/reachability/graph"
|
|
10
11
|
require_relative "../analysis/reachability/plugin_roots"
|
|
11
12
|
require_relative "../analysis/reachability/signature_scan"
|
|
12
13
|
require_relative "../analysis/reachability/project_files"
|
|
14
|
+
require_relative "../cache/store"
|
|
13
15
|
require_relative "options"
|
|
14
16
|
require_relative "command"
|
|
15
17
|
require_relative "probe_environment"
|
|
@@ -22,7 +24,11 @@ module Rigor
|
|
|
22
24
|
# measured precision of this signal is 7.0% on an adjudicated corpus target
|
|
23
25
|
# (`docs/notes/20260813-unused-constant-fp-baseline.md`), so its output is a review queue, not a defect
|
|
24
26
|
# list, and it never enters `rigor check`'s stream at any severity (WD1). Exits 0 whatever it finds.
|
|
25
|
-
|
|
27
|
+
#
|
|
28
|
+
# Like {CheckCommand}, it aggregates one command's concerns — the two scans, the graph, and both
|
|
29
|
+
# render formats — that read clearer together than split across micro-classes, so it carries the
|
|
30
|
+
# same ClassLength exemption.
|
|
31
|
+
class UnusedCommand < Command # rubocop:disable Metrics/ClassLength
|
|
26
32
|
USAGE = "Usage: rigor unused [options] [paths]"
|
|
27
33
|
|
|
28
34
|
# WD7 — the REFERENCE corpus is wider than the ANALYSIS corpus. `.rake` files sit inside `paths:` and
|
|
@@ -43,23 +49,32 @@ module Rigor
|
|
|
43
49
|
|
|
44
50
|
configuration = Configuration.load(options.fetch(:config))
|
|
45
51
|
paths = @argv.empty? ? configuration.paths : @argv
|
|
46
|
-
|
|
52
|
+
scan_cache = Analysis::Reachability::ScanCache.open(configuration.cache_path,
|
|
53
|
+
target_ruby: configuration.target_ruby)
|
|
54
|
+
declarations, references, dynamic_uses = scan(paths, configuration, scan_cache)
|
|
47
55
|
references.concat(signature_references(configuration))
|
|
48
|
-
dynamic_uses.concat(template_mentions(declarations))
|
|
56
|
+
dynamic_uses.concat(template_mentions(declarations, scan_cache))
|
|
49
57
|
|
|
50
|
-
contribution = Analysis::Reachability::PluginRoots.collect(configuration: configuration
|
|
58
|
+
contribution = Analysis::Reachability::PluginRoots.collect(configuration: configuration,
|
|
59
|
+
cache_store: cache_store(configuration))
|
|
51
60
|
references.concat(plugin_references(contribution.references))
|
|
52
|
-
graph =
|
|
53
|
-
|
|
54
|
-
root_fqns: root_fqns(declarations, options.fetch(:entry_points)) + contribution.roots,
|
|
55
|
-
foreign: foreign_predicate(configuration)
|
|
56
|
-
)
|
|
61
|
+
graph = build_graph(configuration, options, contribution,
|
|
62
|
+
declarations: declarations, references: references, dynamic_uses: dynamic_uses)
|
|
57
63
|
emit(graph.report, options, supply: root_supply(contribution.roots, declarations))
|
|
64
|
+
scan_cache.save
|
|
58
65
|
0
|
|
59
66
|
end
|
|
60
67
|
|
|
61
68
|
private
|
|
62
69
|
|
|
70
|
+
def build_graph(configuration, options, contribution, declarations:, references:, dynamic_uses:)
|
|
71
|
+
Analysis::Reachability::Graph.new(
|
|
72
|
+
declarations: declarations, references: references, dynamic_uses: dynamic_uses,
|
|
73
|
+
root_fqns: root_fqns(declarations, options.fetch(:entry_points)) + contribution.roots,
|
|
74
|
+
foreign: foreign_predicate(configuration)
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
63
78
|
def parse_options
|
|
64
79
|
options = { config: nil, format: "text", entry_points: [], limit: nil }
|
|
65
80
|
parser = OptionParser.new do |opts|
|
|
@@ -91,13 +106,15 @@ module Rigor
|
|
|
91
106
|
end
|
|
92
107
|
|
|
93
108
|
# Declarations come from the analysed paths; references additionally from the wider corpus (WD7).
|
|
94
|
-
|
|
109
|
+
# Every file is consulted every run — an unchanged one contributes its cached scan, so the
|
|
110
|
+
# whole-project completeness the `--incremental` refusal protects is unaffected.
|
|
111
|
+
def scan(paths, configuration, cache)
|
|
95
112
|
declaration_files = Analysis::PathExpansion.ruby_files(paths, configuration.exclude_patterns).to_set
|
|
96
113
|
declarations = []
|
|
97
114
|
references = []
|
|
98
115
|
dynamic_uses = []
|
|
99
116
|
(declaration_files + reference_files(paths, configuration)).sort.each do |file|
|
|
100
|
-
result = read_and_scan(file, configuration)
|
|
117
|
+
result = cache.serve(:scan, file) { read_and_scan(file, configuration) }
|
|
101
118
|
next if result.nil?
|
|
102
119
|
|
|
103
120
|
declarations.concat(result.declarations) if declaration_files.include?(file)
|
|
@@ -155,24 +172,38 @@ module Rigor
|
|
|
155
172
|
end
|
|
156
173
|
end
|
|
157
174
|
|
|
158
|
-
def template_mentions(declarations)
|
|
175
|
+
def template_mentions(declarations, cache)
|
|
159
176
|
names = declarations.map(&:fqn)
|
|
160
177
|
return [] if names.empty?
|
|
161
178
|
|
|
162
179
|
Analysis::Reachability::ProjectFiles.own(Dir.glob(TEMPLATE_GLOB, base: Dir.pwd), Dir.pwd).flat_map do |rel|
|
|
163
|
-
|
|
180
|
+
absolute = File.expand_path(rel)
|
|
181
|
+
haystack = cache.serve(:runs, absolute) { constant_bearing_text(File.read(absolute).scrub) }
|
|
164
182
|
names.filter_map do |fqn|
|
|
165
|
-
next unless
|
|
183
|
+
next unless haystack.include?(fqn)
|
|
166
184
|
|
|
167
185
|
Analysis::Reachability::Scan::DynamicUse.new(name: nil, prefix: fqn, site: nil,
|
|
168
186
|
reason: "named as a string in #{rel}",
|
|
169
|
-
path: rel, line: 1)
|
|
187
|
+
path: rel, line: 1, scope: :exact)
|
|
170
188
|
end
|
|
171
189
|
rescue SystemCallError, ArgumentError
|
|
172
190
|
[]
|
|
173
191
|
end
|
|
174
192
|
end
|
|
175
193
|
|
|
194
|
+
# A maximal run of constant-path characters carrying at least one capital — the only substrings a
|
|
195
|
+
# declaration FQN can occur inside, since an FQN starts with a capital and uses this charset alone.
|
|
196
|
+
# An occurrence cannot cross a non-charset character, so `include?` over the de-duplicated runs
|
|
197
|
+
# (joined by a character outside the charset) answers exactly what `include?` over the whole text
|
|
198
|
+
# did, on a fraction of the bytes: a locale YAML or fixture JSON is almost entirely lowercase prose,
|
|
199
|
+
# and scanning 21 MB of it per declaration name was 81% of this command's wall time on mastodon.
|
|
200
|
+
CONSTANT_BEARING_RUN = /[A-Za-z0-9_:]*[A-Z][A-Za-z0-9_:]*/
|
|
201
|
+
private_constant :CONSTANT_BEARING_RUN
|
|
202
|
+
|
|
203
|
+
def constant_bearing_text(text)
|
|
204
|
+
text.scan(CONSTANT_BEARING_RUN).uniq.join("\n")
|
|
205
|
+
end
|
|
206
|
+
|
|
176
207
|
def root_fqns(declarations, globs)
|
|
177
208
|
return [] if globs.empty?
|
|
178
209
|
|
|
@@ -188,13 +219,31 @@ module Rigor
|
|
|
188
219
|
# declaration, which produced three of redmine's artifacts from a single initializer. A name the bundled
|
|
189
220
|
# (non-project) environment already knows is not ours to call unused. The project's own `sig/` is
|
|
190
221
|
# deliberately excluded from this environment, so a project class that ships a signature stays owned.
|
|
222
|
+
#
|
|
223
|
+
# The cache store makes the environment a Marshal restore instead of a cold RBS build on every
|
|
224
|
+
# invocation — the same ADR-54 slot `rigor check` reads, keyed apart by this environment's own
|
|
225
|
+
# (sig-less) descriptor. The report only ever asks "is this name known", which the cached
|
|
226
|
+
# environment answers identically: the one thing the cache degrades is `RBS::Location`, and no
|
|
227
|
+
# location is read here.
|
|
191
228
|
def foreign_predicate(configuration)
|
|
192
|
-
env = Environment.for_project(libraries: configuration.libraries, signature_paths: []
|
|
229
|
+
env = Environment.for_project(libraries: configuration.libraries, signature_paths: [],
|
|
230
|
+
cache_store: cache_store(configuration))
|
|
193
231
|
->(fqn) { !env.singleton_for_name(fqn).nil? }
|
|
194
232
|
rescue StandardError
|
|
195
233
|
->(_fqn) { false }
|
|
196
234
|
end
|
|
197
235
|
|
|
236
|
+
# One store per run, shared by the environment build and the plugin-roots collection. Nil when the
|
|
237
|
+
# store cannot be opened — both consumers already treat a nil store as "recompute", which was this
|
|
238
|
+
# command's only mode before it had a cache at all.
|
|
239
|
+
def cache_store(configuration)
|
|
240
|
+
return @cache_store if defined?(@cache_store)
|
|
241
|
+
|
|
242
|
+
@cache_store = Cache::Store.new(root: configuration.cache_path)
|
|
243
|
+
rescue StandardError
|
|
244
|
+
@cache_store = nil
|
|
245
|
+
end
|
|
246
|
+
|
|
198
247
|
# ADR-102 § Consequences — "a root source that OVER-supplies silently hides real dead code, which is
|
|
199
248
|
# worse than one that under-supplies, so each plugin's contribution needs its own corpus check". A
|
|
200
249
|
# supplied root naming a constant the project does not declare is inert in the graph, but it is the
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -84,19 +84,43 @@ 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
|
|
90
101
|
|
|
91
102
|
def dispatch(command)
|
|
92
103
|
handler = HANDLERS[command]
|
|
93
|
-
return send(handler) if handler
|
|
104
|
+
return arm_jit_deadline { send(handler) } if handler
|
|
94
105
|
|
|
95
106
|
@err.puts("Unknown command: #{command}")
|
|
96
107
|
@err.puts(help)
|
|
97
108
|
EXIT_USAGE
|
|
98
109
|
end
|
|
99
110
|
|
|
111
|
+
# Deferred YJIT for EVERY dispatched command, not just `check` / `coverage` where PR #75 first
|
|
112
|
+
# calibrated it. The deadline makes the decision command-independent: a run that finishes inside
|
|
113
|
+
# the window never pays JIT compile, and a run that outlasts it JITs its dominant tail — measured
|
|
114
|
+
# on `rigor effects` cold over Mastodon, which ran its whole 21 s interpreted while the same
|
|
115
|
+
# analysis under `check` took 14.8 s (forcing YJIT on the effects run: 15.9 s; disabling it on
|
|
116
|
+
# check: 21.1 s). `lsp` / `mcp` still call `Runtime::Jit.enable_now` at boot, which makes the
|
|
117
|
+
# deadline thread armed here a no-op when it later fires.
|
|
118
|
+
def arm_jit_deadline
|
|
119
|
+
require_relative "runtime/jit"
|
|
120
|
+
Runtime::Jit.enable_after(Runtime::Jit.deadline_seconds)
|
|
121
|
+
yield
|
|
122
|
+
end
|
|
123
|
+
|
|
100
124
|
def run_check
|
|
101
125
|
require_relative "cli/check_command"
|
|
102
126
|
|
|
@@ -160,7 +184,7 @@ module Rigor
|
|
|
160
184
|
# - paths: directories scanned by `rigor check` and
|
|
161
185
|
# `rigor type-scan` when no path is given.
|
|
162
186
|
# - plugins: opt-in list of plugin gem names to load.
|
|
163
|
-
# See https://github.com/rigortype/rigor/tree/
|
|
187
|
+
# See https://github.com/rigortype/rigor/tree/master/plugins
|
|
164
188
|
# for production plugins (rigor-activerecord, rigor-sorbet, …).
|
|
165
189
|
# - disable: list of `rigor check` rule identifiers to
|
|
166
190
|
# silence project-wide. The shipped rules are
|
|
@@ -356,13 +380,13 @@ module Rigor
|
|
|
356
380
|
trace Replay how the engine typed FILE as a terminal animation
|
|
357
381
|
type-scan Report Scope#type_of coverage across PATHs
|
|
358
382
|
effects Report each method's effect labels, and the committed effect snapshot
|
|
359
|
-
(
|
|
383
|
+
(opt-in; effects update/check/diff/explain)
|
|
360
384
|
explain Print the description of one or all CheckRules
|
|
361
385
|
diff Compare current diagnostics to a saved baseline JSON
|
|
362
|
-
sig-gen Emit RBS skeletons inferred from .rb sources
|
|
386
|
+
sig-gen Emit RBS skeletons inferred from .rb sources
|
|
363
387
|
lsp Run the Rigor Language Server (LSP) over stdio
|
|
364
|
-
mcp Run the Rigor MCP server over stdio
|
|
365
|
-
triage Summarise diagnostics: distribution, hotspots, hints
|
|
388
|
+
mcp Run the Rigor MCP server over stdio
|
|
389
|
+
triage Summarise diagnostics: distribution, hotspots, hints
|
|
366
390
|
coverage Report type-precision coverage (precise vs Dynamic ratio)
|
|
367
391
|
plugins Report activation status of every configured plugin
|
|
368
392
|
plugin Browse bundled plugin source as worked examples (list/path/print/root)
|
|
@@ -370,9 +394,9 @@ module Rigor
|
|
|
370
394
|
describe Recommend the next skill for this project (alias for `skill describe`)
|
|
371
395
|
skill Recommend the next skill + list/print bundled Agent Skills (skill describe, skill <name>)
|
|
372
396
|
docs Print the bundled docs offline (docs <name>, docs --list)
|
|
373
|
-
show-bleedingedge Show the bleeding-edge overlay + what your config adopts
|
|
374
|
-
doctor Classify setup problems vs clean run with routed next actions
|
|
375
|
-
upgrade Migration command skeleton (
|
|
397
|
+
show-bleedingedge Show the bleeding-edge overlay + what your config adopts
|
|
398
|
+
doctor Classify setup problems vs clean run with routed next actions
|
|
399
|
+
upgrade Migration command skeleton (queued)
|
|
376
400
|
version Print the Rigor version
|
|
377
401
|
help Print this help
|
|
378
402
|
HELP
|