rigortype 0.3.5 → 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/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 +13 -9
- data/docs/manual/03-configuration.md +1 -1
- data/docs/manual/04-diagnostics.md +12 -0
- data/docs/manual/08-skills.md +1 -1
- data/docs/manual/11-ci.md +1 -1
- data/docs/manual/12-caching.md +1 -1
- data/docs/manual/15-type-protection-coverage.md +1 -1
- data/docs/manual/18-removing-dead-code.md +13 -4
- data/docs/manual/19-effect-labels.md +19 -12
- data/docs/manual/README.md +2 -2
- 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/run_cache_key.rb +12 -0
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
- data/lib/rigor/analysis/runner.rb +151 -28
- 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 +27 -2
- data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
- data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
- data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
- data/lib/rigor/cli/unused_command.rb +65 -16
- data/lib/rigor/cli.rb +21 -8
- data/lib/rigor/effects/definition_lines.rb +100 -0
- data/lib/rigor/effects/envelope_check.rb +18 -1
- data/lib/rigor/effects/liskov_check.rb +17 -8
- data/lib/rigor/effects/signature_sources.rb +13 -2
- data/lib/rigor/effects/snapshot.rb +53 -21
- data/lib/rigor/effects/snapshot_diff.rb +26 -3
- data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
- data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
- data/lib/rigor/version.rb +1 -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 +5 -1
|
@@ -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
|
@@ -101,13 +101,26 @@ module Rigor
|
|
|
101
101
|
|
|
102
102
|
def dispatch(command)
|
|
103
103
|
handler = HANDLERS[command]
|
|
104
|
-
return send(handler) if handler
|
|
104
|
+
return arm_jit_deadline { send(handler) } if handler
|
|
105
105
|
|
|
106
106
|
@err.puts("Unknown command: #{command}")
|
|
107
107
|
@err.puts(help)
|
|
108
108
|
EXIT_USAGE
|
|
109
109
|
end
|
|
110
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
|
+
|
|
111
124
|
def run_check
|
|
112
125
|
require_relative "cli/check_command"
|
|
113
126
|
|
|
@@ -367,13 +380,13 @@ module Rigor
|
|
|
367
380
|
trace Replay how the engine typed FILE as a terminal animation
|
|
368
381
|
type-scan Report Scope#type_of coverage across PATHs
|
|
369
382
|
effects Report each method's effect labels, and the committed effect snapshot
|
|
370
|
-
(
|
|
383
|
+
(opt-in; effects update/check/diff/explain)
|
|
371
384
|
explain Print the description of one or all CheckRules
|
|
372
385
|
diff Compare current diagnostics to a saved baseline JSON
|
|
373
|
-
sig-gen Emit RBS skeletons inferred from .rb sources
|
|
386
|
+
sig-gen Emit RBS skeletons inferred from .rb sources
|
|
374
387
|
lsp Run the Rigor Language Server (LSP) over stdio
|
|
375
|
-
mcp Run the Rigor MCP server over stdio
|
|
376
|
-
triage Summarise diagnostics: distribution, hotspots, hints
|
|
388
|
+
mcp Run the Rigor MCP server over stdio
|
|
389
|
+
triage Summarise diagnostics: distribution, hotspots, hints
|
|
377
390
|
coverage Report type-precision coverage (precise vs Dynamic ratio)
|
|
378
391
|
plugins Report activation status of every configured plugin
|
|
379
392
|
plugin Browse bundled plugin source as worked examples (list/path/print/root)
|
|
@@ -381,9 +394,9 @@ module Rigor
|
|
|
381
394
|
describe Recommend the next skill for this project (alias for `skill describe`)
|
|
382
395
|
skill Recommend the next skill + list/print bundled Agent Skills (skill describe, skill <name>)
|
|
383
396
|
docs Print the bundled docs offline (docs <name>, docs --list)
|
|
384
|
-
show-bleedingedge Show the bleeding-edge overlay + what your config adopts
|
|
385
|
-
doctor Classify setup problems vs clean run with routed next actions
|
|
386
|
-
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)
|
|
387
400
|
version Print the Rigor version
|
|
388
401
|
help Print this help
|
|
389
402
|
HELP
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "method_key"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Effects
|
|
9
|
+
# Where a method key's `def` is written, resolved from the file the key was already traced to
|
|
10
|
+
# (#435).
|
|
11
|
+
#
|
|
12
|
+
# A drift row names the file out of `Runner#effect_sources`, which rides the cached summary entry
|
|
13
|
+
# and is therefore free. The **line** is not in any value the effects surfaces hold: the discovery
|
|
14
|
+
# tables {EnvelopeCheck::Positions} reads are built by one Prism parse of every project file, which
|
|
15
|
+
# is exactly what [ADR-104](../../../docs/adr/104-effects-boot-slim-probe.md) removed from this
|
|
16
|
+
# command — a warm `rigor effects check` is fast *because* it never parses the project.
|
|
17
|
+
#
|
|
18
|
+
# So this parses the drift's own files and nothing else: an index is built the first time a row asks
|
|
19
|
+
# about a path, and a report with no rows builds none. The cost is proportional to the drift, not to
|
|
20
|
+
# the project — the {EnvelopeCheck::DeferredPositions} shape (#479) applied one layer up, and the
|
|
21
|
+
# reason it can be one layer up is that the caller already knows the file.
|
|
22
|
+
#
|
|
23
|
+
# It is deliberately not a second discovery pass. It answers `def`s and only `def`s: a key whose
|
|
24
|
+
# method has no Ruby `def` at all — a synthesized accessor — keeps the file and loses the line,
|
|
25
|
+
# which is the same degradation `Positions` makes when it falls back to the class's own source.
|
|
26
|
+
class DefinitionLines
|
|
27
|
+
TOPLEVEL = "<toplevel>"
|
|
28
|
+
private_constant :TOPLEVEL
|
|
29
|
+
|
|
30
|
+
NO_LINES = {}.freeze
|
|
31
|
+
private_constant :NO_LINES
|
|
32
|
+
|
|
33
|
+
# @param key [String] an effect unit key — `Tracer::Loud#emit`, `Net::HTTP.get`.
|
|
34
|
+
# @param path [String] the file the key was traced to.
|
|
35
|
+
# @return [Integer, nil] the `def`'s line, or nil when this file does not spell that key with a
|
|
36
|
+
# `def` — an unreadable file, a syntax error, and a synthesized method all land here.
|
|
37
|
+
def for(key:, path:)
|
|
38
|
+
index_for(path)[key]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def index_for(path)
|
|
44
|
+
@indexes ||= {}
|
|
45
|
+
@indexes[path] ||= build_index(path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# First `def` wins: a file that reopens the same method twice has two lines and only one of them is
|
|
49
|
+
# where a reader starts.
|
|
50
|
+
def build_index(path)
|
|
51
|
+
result = Prism.parse_file(path.to_s)
|
|
52
|
+
return NO_LINES unless result.success?
|
|
53
|
+
|
|
54
|
+
{}.tap { |index| walk(result.value, [], singleton: false, index: index) }
|
|
55
|
+
rescue StandardError
|
|
56
|
+
NO_LINES
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The nesting is tracked, never resolved: `class Tracer::Loud` inside `module Tracer` spells a key
|
|
60
|
+
# this cannot see, and answering it would need the constant resolution an engine-free path does not
|
|
61
|
+
# have. A key it cannot spell keeps its file, which is what the row printed before this class existed.
|
|
62
|
+
def walk(node, nesting, singleton:, index:)
|
|
63
|
+
case node
|
|
64
|
+
when Prism::ModuleNode, Prism::ClassNode
|
|
65
|
+
name = constant_name(node.constant_path)
|
|
66
|
+
return if name.nil?
|
|
67
|
+
|
|
68
|
+
walk_children(node.body, nesting + [name], singleton: false, index: index)
|
|
69
|
+
when Prism::SingletonClassNode
|
|
70
|
+
walk_children(node.body, nesting, singleton: true, index: index)
|
|
71
|
+
when Prism::DefNode
|
|
72
|
+
record(node, nesting, singleton: singleton, index: index)
|
|
73
|
+
walk_children(node.body, nesting, singleton: singleton, index: index)
|
|
74
|
+
else
|
|
75
|
+
walk_children(node, nesting, singleton: singleton, index: index)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def walk_children(node, nesting, singleton:, index:)
|
|
80
|
+
node&.compact_child_nodes&.each { |child| walk(child, nesting, singleton: singleton, index: index) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def record(node, nesting, singleton:, index:)
|
|
84
|
+
separator = singleton || node.receiver.is_a?(Prism::SelfNode) ? "." : "#"
|
|
85
|
+
owner = nesting.empty? ? TOPLEVEL : nesting.join("::")
|
|
86
|
+
key = "#{owner}#{separator}#{node.name}"
|
|
87
|
+
index[key] ||= node.location.start_line
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def constant_name(node)
|
|
91
|
+
case node
|
|
92
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
93
|
+
when Prism::ConstantPathNode then node.full_name
|
|
94
|
+
end
|
|
95
|
+
rescue StandardError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -83,6 +83,21 @@ module Rigor
|
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
# {Positions} behind a thunk: the discovery tables — one Prism parse of every project file —
|
|
87
|
+
# are built on the first `.for`, which both judgments reach only once a finding is being
|
|
88
|
+
# constructed. A clean judgment, the common CI case, therefore forces no discovery and parses
|
|
89
|
+
# nothing; that is the whole point of this class existing rather than the pass forcing the
|
|
90
|
+
# tables up front.
|
|
91
|
+
class DeferredPositions
|
|
92
|
+
def initialize(&build)
|
|
93
|
+
@build = build
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def for(key)
|
|
97
|
+
(@positions ||= @build.call).for(key)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
86
101
|
NO_FINDINGS = [].freeze
|
|
87
102
|
private_constant :NO_FINDINGS
|
|
88
103
|
|
|
@@ -93,7 +108,9 @@ module Rigor
|
|
|
93
108
|
# @param class_envelopes [Hash{String => Envelope}] class- / module-level envelopes, to distribute.
|
|
94
109
|
# @param config_envelopes [Hash{String => Envelope}] `effects.envelopes:` entries already resolved
|
|
95
110
|
# to the classes they select ({ConfigEnvelopes.for_classes}), to distribute at the lowest precedence.
|
|
96
|
-
# @param positions [Positions] the discovery tables a finding's `def`
|
|
111
|
+
# @param positions [Positions, DeferredPositions] the discovery tables a finding's `def`
|
|
112
|
+
# position is read from — consulted only when a finding is built, so a deferred value's
|
|
113
|
+
# discovery force is reached exactly as often as a finding exists.
|
|
97
114
|
# @param apply_tolerated [Boolean] false judges against the undischarged-by-policy `proven` lane —
|
|
98
115
|
# the `--no-tolerated-effects` audit switch.
|
|
99
116
|
# @return [Array<Finding>] sorted by position then key then label, so a run explains identically twice.
|
|
@@ -112,11 +112,13 @@ module Rigor
|
|
|
112
112
|
|
|
113
113
|
inherited = envelopes.fetch(ancestor_key)
|
|
114
114
|
own = envelopes[key]
|
|
115
|
-
position
|
|
115
|
+
# The position is read inside the two collectors, after they know a finding exists: `.for` is
|
|
116
|
+
# what forces a deferred position table, and an inherited envelope nothing widens must not
|
|
117
|
+
# cost a whole-project discovery parse.
|
|
116
118
|
if own && !own.top?
|
|
117
|
-
collect_declared(findings, key, ancestor_key, inherited, own,
|
|
119
|
+
collect_declared(findings, key, ancestor_key, inherited, own, positions)
|
|
118
120
|
else
|
|
119
|
-
collect_proven(findings, table, key, ancestor_key, inherited,
|
|
121
|
+
collect_proven(findings, table, key, ancestor_key, inherited, positions, apply_tolerated)
|
|
120
122
|
end
|
|
121
123
|
end
|
|
122
124
|
|
|
@@ -135,16 +137,19 @@ module Rigor
|
|
|
135
137
|
nil
|
|
136
138
|
end
|
|
137
139
|
|
|
138
|
-
def collect_proven(findings, table, key, ancestor_key, inherited,
|
|
140
|
+
def collect_proven(findings, table, key, ancestor_key, inherited, positions, apply_tolerated)
|
|
139
141
|
entry = table[key]
|
|
140
142
|
return if entry.nil?
|
|
141
143
|
|
|
142
144
|
exceeding = inherited.exceeded_by(apply_tolerated ? entry.undischarged : entry.proven)
|
|
145
|
+
return if exceeding.empty?
|
|
146
|
+
|
|
147
|
+
path, line = positions.for(key)
|
|
143
148
|
exceeding.each do |label|
|
|
144
149
|
trail = PathFinder.shortest(table, symbol: key, label: label)
|
|
145
150
|
findings << Finding.new(
|
|
146
151
|
key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
|
|
147
|
-
own_envelope: nil, path:
|
|
152
|
+
own_envelope: nil, path: path, line: line,
|
|
148
153
|
chain: trail&.chain || [key].freeze, origin: trail&.origin
|
|
149
154
|
)
|
|
150
155
|
end
|
|
@@ -152,11 +157,15 @@ module Rigor
|
|
|
152
157
|
|
|
153
158
|
# Two authored bounds, compared by subsumption alone. `mutate.local` is tolerated here as it is
|
|
154
159
|
# everywhere, so declaring it under an inherited `%a{pure}` is not a widening.
|
|
155
|
-
def collect_declared(findings, key, ancestor_key, inherited, own,
|
|
156
|
-
own.bound.to_a.reject { |label| inherited.tolerates?(label) }
|
|
160
|
+
def collect_declared(findings, key, ancestor_key, inherited, own, positions)
|
|
161
|
+
widened = own.bound.to_a.reject { |label| inherited.tolerates?(label) }
|
|
162
|
+
return if widened.empty?
|
|
163
|
+
|
|
164
|
+
path, line = positions.for(key)
|
|
165
|
+
widened.each do |label|
|
|
157
166
|
findings << Finding.new(
|
|
158
167
|
key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
|
|
159
|
-
own_envelope: own, path:
|
|
168
|
+
own_envelope: own, path: path, line: line, chain: nil, origin: nil
|
|
160
169
|
)
|
|
161
170
|
end
|
|
162
171
|
end
|
|
@@ -21,10 +21,21 @@ module Rigor
|
|
|
21
21
|
DEFAULT_ROOTS = ["sig"].freeze
|
|
22
22
|
|
|
23
23
|
# A cheap text pre-filter for "does this source carry an effect annotation at all". It matches
|
|
24
|
-
# the two
|
|
24
|
+
# the two payloads the envelope reader honours and nothing else, so a signature tree with no
|
|
25
25
|
# effect annotation is answered by one regex per file and never parsed. It is a ROUTING test,
|
|
26
26
|
# not the grammar — `RbsExtended.parse_effect_annotation` is still what decides meaning.
|
|
27
|
-
|
|
27
|
+
#
|
|
28
|
+
# RBS accepts five bracket pairs for an annotation, and the reader sees only the text inside
|
|
29
|
+
# them, so the hint must accept all five too: routing on `%a{` alone made a `%a(pure)` project
|
|
30
|
+
# invisible to the run-cache probe, which then served the fast path while a bound existed —
|
|
31
|
+
# the silent-lane shape the #428 family is about. Over-matching (a mismatched closer) is safe:
|
|
32
|
+
# the cost is one declined fast path or one parsed file, never a missed bound.
|
|
33
|
+
ANNOTATION_BRACKETS = { "{" => "}", "(" => ")", "[" => "]", "|" => "|", "<" => ">" }.freeze
|
|
34
|
+
ANNOTATION_HINT = Regexp.union(
|
|
35
|
+
ANNOTATION_BRACKETS.map do |opener, closer|
|
|
36
|
+
/%a#{Regexp.escape(opener)}\s*(?:pure\s*#{Regexp.escape(closer)}|rigor:v1:effect\b)/
|
|
37
|
+
end
|
|
38
|
+
)
|
|
28
39
|
|
|
29
40
|
# A `virtual:<plugin-id>:<source path>` buffer is rbs-inline's (or a plugin's) synthesized RBS for
|
|
30
41
|
# a Ruby file the author actually wrote in. Naming that file is what a reader can act on, so the
|
|
@@ -38,7 +38,10 @@ module Rigor
|
|
|
38
38
|
class Snapshot
|
|
39
39
|
# Bumped when the file's shape changes in a way an older reader would misread. A bump makes every
|
|
40
40
|
# existing file a regeneration event rather than a silent reinterpretation.
|
|
41
|
-
|
|
41
|
+
# 2 — `unresolved:` became a count (#434). A schema-1 file still loads, and its only reported
|
|
42
|
+
# difference is this field, so an existing project sees one regeneration line rather than a
|
|
43
|
+
# reinterpretation.
|
|
44
|
+
SCHEMA = 2
|
|
42
45
|
|
|
43
46
|
HEADER = "# .rigor-effects.yml — generated by `rigor effects update`. Commit it; review its diff."
|
|
44
47
|
|
|
@@ -47,8 +50,16 @@ module Rigor
|
|
|
47
50
|
|
|
48
51
|
# One row of either table.
|
|
49
52
|
#
|
|
50
|
-
# `effects` and `declared` are sorted label arrays; `unresolved` is
|
|
51
|
-
#
|
|
53
|
+
# `effects` and `declared` are sorted label arrays; `unresolved` is HOW MANY taint causes sit behind
|
|
54
|
+
# a false `exhaustive`, and is zero when the row is exhaustive.
|
|
55
|
+
#
|
|
56
|
+
# A count rather than the causes themselves (#434). On redmine the cause renderings were 144,771 of
|
|
57
|
+
# the file's 293,276 bytes, on lines up to 821 characters — simultaneously the half a reviewer
|
|
58
|
+
# cannot read and the half that churns when an unrelated call moves. This file already makes that
|
|
59
|
+
# argument about origins ("a record keyed by them would churn on every refactor"); the causes are
|
|
60
|
+
# the same class of thing and were the one place it was not applied. What a reviewer needs from the
|
|
61
|
+
# record is the stable fact that the row is not exhaustive and by how much, which is what a count
|
|
62
|
+
# is; `rigor effects explain` names the causes on demand, against the live table.
|
|
52
63
|
class Entry < Data.define(:key, :effects, :declared, :exhaustive, :unresolved)
|
|
53
64
|
def exhaustive?
|
|
54
65
|
exhaustive
|
|
@@ -61,7 +72,7 @@ module Rigor
|
|
|
61
72
|
row = { "effects" => effects }
|
|
62
73
|
row["declared"] = declared unless declared.empty?
|
|
63
74
|
row["exhaustive"] = false unless exhaustive
|
|
64
|
-
row["unresolved"] = unresolved unless unresolved.
|
|
75
|
+
row["unresolved"] = unresolved unless unresolved.zero?
|
|
65
76
|
row
|
|
66
77
|
end
|
|
67
78
|
|
|
@@ -73,10 +84,23 @@ module Rigor
|
|
|
73
84
|
effects: string_list(row["effects"], key, "effects"),
|
|
74
85
|
declared: string_list(row["declared"], key, "declared"),
|
|
75
86
|
exhaustive: row.fetch("exhaustive", true) != false,
|
|
76
|
-
unresolved:
|
|
87
|
+
unresolved: unresolved_count(row["unresolved"], key)
|
|
77
88
|
)
|
|
78
89
|
end
|
|
79
90
|
|
|
91
|
+
# Tolerant of the schema-1 spelling, which was the list itself: the field's whole point now is a
|
|
92
|
+
# stable number, and a schema-1 file must LOAD so the header's own mismatch can be reported as
|
|
93
|
+
# the one regeneration event it is. Refusing to parse it would turn a migration into an error.
|
|
94
|
+
def self.unresolved_count(value, key)
|
|
95
|
+
case value
|
|
96
|
+
when nil then 0
|
|
97
|
+
when Integer then value
|
|
98
|
+
when Array then value.length
|
|
99
|
+
else raise ParseError, "#{key.inspect} unresolved: expected a count, got #{value.class}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
private_class_method :unresolved_count
|
|
103
|
+
|
|
80
104
|
def self.string_list(value, key, field)
|
|
81
105
|
return [].freeze if value.nil?
|
|
82
106
|
raise ParseError, "#{key.inspect} #{field}: expected a list, got #{value.class}" unless value.is_a?(Array)
|
|
@@ -185,6 +209,29 @@ module Rigor
|
|
|
185
209
|
parse(File.read(path, encoding: "UTF-8"))
|
|
186
210
|
end
|
|
187
211
|
|
|
212
|
+
# A path as a reader sees it: project-relative when it is under the root, absolute when it is not.
|
|
213
|
+
#
|
|
214
|
+
# Public for the same reason {.render_causes} is — `reach:`'s entry-point globs and a drift row's
|
|
215
|
+
# source suffix (#435) must agree about what a project-relative path is, and a report that leaked
|
|
216
|
+
# `/private/tmp/nix-shell.../loud.rb` into a reviewer's terminal would be answering with this
|
|
217
|
+
# machine's layout rather than with the project's.
|
|
218
|
+
def relativize(path, project_root)
|
|
219
|
+
absolute = File.absolute_path(path.to_s)
|
|
220
|
+
root = "#{File.absolute_path(project_root.to_s).chomp('/')}/"
|
|
221
|
+
absolute.start_with?(root) ? absolute[root.length..] : absolute
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The taint causes as a reviewer reads them, from the closed enum of
|
|
225
|
+
# `docs/type-specification/effect-labels.md` — `dynamic-send`, `unresolved-self-call(save!)`.
|
|
226
|
+
#
|
|
227
|
+
# Public because two surfaces render the same causes and must not disagree about what one IS:
|
|
228
|
+
# a row's `unresolved:` count is the length of this list, and `rigor effects explain` prints the
|
|
229
|
+
# list itself for an `exhaustive → not` drift row (#434 / #435). One implementation, as WD3 asks.
|
|
230
|
+
def render_causes(causes)
|
|
231
|
+
causes.map { |cause, detail| detail.nil? || detail.empty? ? cause : "#{cause}(#{detail})" }
|
|
232
|
+
.uniq.sort.freeze
|
|
233
|
+
end
|
|
234
|
+
|
|
188
235
|
private
|
|
189
236
|
|
|
190
237
|
def parse_header(data)
|
|
@@ -241,12 +288,6 @@ module Rigor
|
|
|
241
288
|
end
|
|
242
289
|
end
|
|
243
290
|
|
|
244
|
-
def relativize(path, project_root)
|
|
245
|
-
absolute = File.absolute_path(path.to_s)
|
|
246
|
-
root = "#{File.absolute_path(project_root.to_s).chomp('/')}/"
|
|
247
|
-
absolute.start_with?(root) ? absolute[root.length..] : absolute
|
|
248
|
-
end
|
|
249
|
-
|
|
250
291
|
# Each table takes the lanes at ITS own reading: `methods:` records the direct summary, so its
|
|
251
292
|
# `declared:` is what this method's own body claims, and `reach:` records the transitive one, so
|
|
252
293
|
# its `declared:` is the fixpoint's — a controller reaching an attributed gem call through two
|
|
@@ -261,19 +302,10 @@ module Rigor
|
|
|
261
302
|
effects: proven.to_a,
|
|
262
303
|
declared: declared.excluding_subsumed_by(proven).to_a,
|
|
263
304
|
exhaustive: exhaustive,
|
|
264
|
-
unresolved: exhaustive ?
|
|
305
|
+
unresolved: exhaustive ? 0 : render_causes(causes).length
|
|
265
306
|
)
|
|
266
307
|
end
|
|
267
308
|
|
|
268
|
-
# `unresolved:` says why the row is not exhaustive, from the closed taint-cause enum of
|
|
269
|
-
# `docs/type-specification/effect-labels.md` — `dynamic-send`, `unresolved-self-call(save!)`. The
|
|
270
|
-
# design note sketched this field as "call names"; the collector keeps causes rather than names,
|
|
271
|
-
# and for the causes that have one the detail already IS the call name.
|
|
272
|
-
def render_causes(causes)
|
|
273
|
-
causes.map { |cause, detail| detail.nil? || detail.empty? ? cause : "#{cause}(#{detail})" }
|
|
274
|
-
.uniq.sort.freeze
|
|
275
|
-
end
|
|
276
|
-
|
|
277
309
|
# A synthesised default summary — an `attr_*` writer's `mutate.self`, and the `Struct` / `Data`
|
|
278
310
|
# accessors that join it when discovery synthesises them — carries no information a reviewer acts
|
|
279
311
|
# on: it restates the `attr_accessor` line. Recognised by its origins being exactly the synthesised
|
|
@@ -107,6 +107,7 @@ module Rigor
|
|
|
107
107
|
@undischarged = undischarged
|
|
108
108
|
@added_symbols = 0
|
|
109
109
|
@removed_symbols = 0
|
|
110
|
+
@suppressed = 0
|
|
110
111
|
@events = build_events.freeze
|
|
111
112
|
freeze
|
|
112
113
|
end
|
|
@@ -123,9 +124,16 @@ module Rigor
|
|
|
123
124
|
end
|
|
124
125
|
|
|
125
126
|
# Renames are a removal plus an addition and are never reported as a lost effect; the footer is
|
|
126
|
-
# where the reviewer sees that the two counts balance.
|
|
127
|
+
# where the reviewer sees that the two counts balance. `suppressed` is the per-symbol events a
|
|
128
|
+
# regeneration event withheld — zero on an ordinary comparison.
|
|
127
129
|
def footer
|
|
128
|
-
{ added_symbols: @added_symbols, removed_symbols: @removed_symbols }
|
|
130
|
+
{ added_symbols: @added_symbols, removed_symbols: @removed_symbols, suppressed: @suppressed }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Whether the two sides were computed under different rules (#434). The per-symbol comparison is
|
|
134
|
+
# then meaningless rather than merely noisy, which is why {#build_events} withholds it.
|
|
135
|
+
def regeneration?
|
|
136
|
+
@events.any? { |event| event.category == REGENERATION }
|
|
129
137
|
end
|
|
130
138
|
|
|
131
139
|
def events_for(table)
|
|
@@ -145,10 +153,25 @@ module Rigor
|
|
|
145
153
|
ADDITIVE_CATEGORIES.include?(event.category)
|
|
146
154
|
end
|
|
147
155
|
|
|
156
|
+
# A regeneration event withholds the per-symbol comparison rather than printing it (#434).
|
|
157
|
+
#
|
|
158
|
+
# The header says the record was written under different rules, so the two sides are not
|
|
159
|
+
# comparable — a claim this class already makes in its own documentation and then contradicted by
|
|
160
|
+
# emitting every row anyway. On redmine a moved `config_digest:` produced one regeneration line
|
|
161
|
+
# followed by 482 `-symbol` lines, none of which was a review signal: they say the recorded set was
|
|
162
|
+
# computed differently, which the header already said once.
|
|
163
|
+
#
|
|
164
|
+
# The table comparison still RUNS, because its per-symbol counters are what tell the reader the
|
|
165
|
+
# scale of what is withheld. Only the events are dropped.
|
|
148
166
|
def build_events
|
|
149
167
|
return [missing_snapshot_event] if @recorded.nil?
|
|
150
168
|
|
|
151
|
-
|
|
169
|
+
header = header_events
|
|
170
|
+
table = TABLES.flat_map { |name| table_events(name) }
|
|
171
|
+
return table if header.empty?
|
|
172
|
+
|
|
173
|
+
@suppressed = table.length
|
|
174
|
+
header
|
|
152
175
|
end
|
|
153
176
|
|
|
154
177
|
def missing_snapshot_event
|
|
@@ -65,6 +65,13 @@ module Rigor
|
|
|
65
65
|
registries = collect_trait_registries(plugin_registry)
|
|
66
66
|
nested_templates = collect_nested_class_templates(plugin_registry)
|
|
67
67
|
return SyntheticMethodIndex::EMPTY if templates.empty? && registries.empty? && nested_templates.empty?
|
|
68
|
+
# Tier B alone cannot emit without an environment: every trait entry — the direct route and the
|
|
69
|
+
# concern-re-targeted one — funnels through `module_instance_method_names`, whose first line
|
|
70
|
+
# answers `[]` for a nil environment. The production pre-pass passes `environment: nil` (#476),
|
|
71
|
+
# so a project whose only contributing plugin registers trait registries (rigor-devise on a
|
|
72
|
+
# Rails app) would otherwise pay a whole-project parse to build a provably empty index. This
|
|
73
|
+
# gate mirrors that nil guard and MUST be removed in the change that threads a real environment.
|
|
74
|
+
return SyntheticMethodIndex::EMPTY if environment.nil? && templates.empty? && nested_templates.empty?
|
|
68
75
|
|
|
69
76
|
asts = parse_paths(paths, buffer: buffer)
|
|
70
77
|
hierarchy = build_hierarchy(asts)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "rbs"
|
|
4
4
|
|
|
5
|
+
require_relative "../effects/signature_sources"
|
|
5
6
|
require_relative "../rbs_extended"
|
|
6
7
|
require_relative "reporter"
|
|
7
8
|
|
|
@@ -50,11 +51,19 @@ module Rigor
|
|
|
50
51
|
# `.rbs` files, plus the virtual entries rbs-inline and plugin `source_rbs` synthesis contribute.
|
|
51
52
|
# @param registry [Rigor::Effects::Registry] the vocabulary an unknown label is judged against.
|
|
52
53
|
# @return [Result]
|
|
54
|
+
#
|
|
55
|
+
# The `ANNOTATION_HINT` routing test runs here, before any parse: a source with no honoured
|
|
56
|
+
# payload can contribute neither an envelope nor an unresolved report, so a signature tree with
|
|
57
|
+
# no effect annotation is answered by one regex per file — which is what that hint's own
|
|
58
|
+
# documentation promises, and what every warm envelope-lane run was paying a full
|
|
59
|
+
# `RBS::Parser.parse_signature` per file for.
|
|
53
60
|
def scan(sources:, registry:)
|
|
54
61
|
reporter = Reporter.new
|
|
55
62
|
methods = {}
|
|
56
63
|
classes = {}
|
|
57
64
|
sources.each do |name, content|
|
|
65
|
+
next unless Effects::SignatureSources::ANNOTATION_HINT.match?(content)
|
|
66
|
+
|
|
58
67
|
declarations(name, content).each do |decl|
|
|
59
68
|
walk(decl, [], methods, classes, registry, reporter)
|
|
60
69
|
end
|