rigortype 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/data/builtins/ruby_core/array.yml +11 -24
- data/data/builtins/ruby_core/enumerable.yml +4 -8
- data/data/builtins/ruby_core/exception.yml +6 -9
- data/data/builtins/ruby_core/hash.yml +13 -20
- data/data/builtins/ruby_core/io.yml +6 -9
- data/data/builtins/ruby_core/numeric.yml +2 -3
- data/data/builtins/ruby_core/proc.yml +4 -8
- data/data/builtins/ruby_core/re.yml +6 -9
- data/data/builtins/ruby_core/set.yml +2 -3
- data/data/builtins/ruby_core/string.yml +4 -7
- data/data/builtins/ruby_core/struct.yml +0 -6
- data/data/builtins/ruby_core/time.yml +3 -6
- data/data/effects/core.yml +860 -0
- data/data/effects/registry.yml +95 -0
- data/docs/handbook/07-rbs-and-extended.md +106 -5
- data/docs/manual/02-cli-reference.md +359 -2
- data/docs/manual/03-configuration.md +101 -1
- data/docs/manual/04-diagnostics.md +8 -3
- data/docs/manual/11-ci.md +37 -0
- data/docs/manual/12-caching.md +39 -0
- data/docs/manual/16-rbs-extended-annotations.md +90 -0
- data/docs/manual/18-removing-dead-code.md +326 -0
- data/docs/manual/19-effect-labels.md +671 -0
- data/docs/manual/README.md +10 -0
- data/docs/manual/ci-templates/README.md +9 -0
- data/docs/manual/plugins/rigor-actionmailer.md +5 -0
- data/docs/manual/plugins/rigor-activejob.md +47 -0
- data/docs/manual/plugins/rigor-factorybot.md +16 -0
- data/docs/manual/plugins/rigor-pundit.md +26 -0
- data/docs/manual/plugins/rigor-rails-routes.md +41 -2
- data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
- data/docs/manual/plugins/rigor-rspec.md +8 -0
- data/docs/manual/plugins/rigor-sidekiq.md +54 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
- data/lib/rigor/analysis/incremental_session.rb +140 -6
- data/lib/rigor/analysis/reachability/graph.rb +244 -0
- data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
- data/lib/rigor/analysis/reachability/project_files.rb +58 -0
- data/lib/rigor/analysis/reachability/scan.rb +241 -0
- data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
- data/lib/rigor/analysis/rule_catalog.rb +168 -3
- data/lib/rigor/analysis/run_cache_key.rb +16 -0
- data/lib/rigor/analysis/run_cache_probe.rb +69 -1
- data/lib/rigor/analysis/runner/declaration_position.rb +38 -0
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
- data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +93 -10
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
- data/lib/rigor/analysis/runner.rb +429 -10
- data/lib/rigor/analysis/worker_session.rb +60 -2
- data/lib/rigor/bleeding_edge.rb +22 -0
- data/lib/rigor/cache/incremental_snapshot.rb +24 -5
- data/lib/rigor/cli/check_command.rb +13 -1
- data/lib/rigor/cli/check_runner_factory.rb +4 -1
- data/lib/rigor/cli/effects_command.rb +245 -0
- data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
- data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
- data/lib/rigor/cli/effects_renderer.rb +92 -0
- data/lib/rigor/cli/effects_report.rb +163 -0
- data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
- data/lib/rigor/cli/unused_command.rb +288 -0
- data/lib/rigor/cli.rb +28 -1
- data/lib/rigor/configuration/severity_profile.rb +23 -0
- data/lib/rigor/configuration.rb +322 -28
- data/lib/rigor/configuration_error.rb +20 -0
- data/lib/rigor/effects/attribution.rb +76 -0
- data/lib/rigor/effects/catalog.rb +275 -0
- data/lib/rigor/effects/collector.rb +219 -0
- data/lib/rigor/effects/config_envelopes.rb +185 -0
- data/lib/rigor/effects/discharge.rb +69 -0
- data/lib/rigor/effects/effect_table.rb +92 -0
- data/lib/rigor/effects/entry_points.rb +139 -0
- data/lib/rigor/effects/envelope.rb +86 -0
- data/lib/rigor/effects/envelope_check.rb +172 -0
- data/lib/rigor/effects/envelope_index.rb +157 -0
- data/lib/rigor/effects/file_collection.rb +174 -0
- data/lib/rigor/effects/framework_units.rb +222 -0
- data/lib/rigor/effects/identity.rb +104 -0
- data/lib/rigor/effects/inline_anchor.rb +134 -0
- data/lib/rigor/effects/label.rb +77 -0
- data/lib/rigor/effects/label_intent.rb +73 -0
- data/lib/rigor/effects/label_set.rb +136 -0
- data/lib/rigor/effects/liskov_check.rb +167 -0
- data/lib/rigor/effects/local_ownership.rb +132 -0
- data/lib/rigor/effects/method_key.rb +40 -0
- data/lib/rigor/effects/mutation_classifier.rb +92 -0
- data/lib/rigor/effects/narrowing.rb +202 -0
- data/lib/rigor/effects/origin.rb +66 -0
- data/lib/rigor/effects/path_finder.rb +89 -0
- data/lib/rigor/effects/plugin_facts.rb +384 -0
- data/lib/rigor/effects/propagator.rb +335 -0
- data/lib/rigor/effects/registry.rb +200 -0
- data/lib/rigor/effects/scanner.rb +302 -0
- data/lib/rigor/effects/signature_sources.rb +90 -0
- data/lib/rigor/effects/snapshot.rb +396 -0
- data/lib/rigor/effects/snapshot_diff.rb +265 -0
- data/lib/rigor/effects/summary.rb +154 -0
- data/lib/rigor/effects/taint_cause.rb +39 -0
- data/lib/rigor/effects/unit_scan.rb +647 -0
- data/lib/rigor/effects/unknown_label_check.rb +86 -0
- data/lib/rigor/effects/unknown_label_report.rb +59 -0
- data/lib/rigor/effects.rb +47 -0
- data/lib/rigor/environment/rbs_loader.rb +34 -0
- data/lib/rigor/environment.rb +8 -1
- data/lib/rigor/flow_contribution/element.rb +1 -0
- data/lib/rigor/flow_contribution/merge_result.rb +5 -3
- data/lib/rigor/flow_contribution/merger.rb +16 -1
- data/lib/rigor/flow_contribution.rb +20 -4
- data/lib/rigor/inference/expression_typer.rb +18 -4
- data/lib/rigor/inference/pre_eval_constants.rb +189 -0
- data/lib/rigor/inference/scope_indexer.rb +15 -3
- data/lib/rigor/plugin/base.rb +28 -0
- data/lib/rigor/plugin/box.rb +18 -2
- data/lib/rigor/plugin/effect_ancestry.rb +80 -0
- data/lib/rigor/plugin/effect_attribution.rb +208 -0
- data/lib/rigor/plugin/effect_edge.rb +101 -0
- data/lib/rigor/plugin/effect_entry_points.rb +51 -0
- data/lib/rigor/plugin/first_party.rb +57 -0
- data/lib/rigor/plugin/isolation.rb +4 -1
- data/lib/rigor/plugin/manifest.rb +127 -3
- data/lib/rigor/plugin/registry.rb +55 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
- data/lib/rigor/rbs_extended.rb +188 -0
- data/lib/rigor/reflection.rb +131 -30
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +401 -29
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
- data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
- data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
- data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
- data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -3
- data/sig/rigor/analysis/fact_store.rbs +1 -0
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor/rbs_extended.rbs +6 -0
- data/sig/rigor.rbs +17 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- metadata +84 -1
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optionparser"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "../configuration"
|
|
7
|
+
require_relative "../analysis/path_expansion"
|
|
8
|
+
require_relative "../analysis/reachability/scan"
|
|
9
|
+
require_relative "../analysis/reachability/graph"
|
|
10
|
+
require_relative "../analysis/reachability/plugin_roots"
|
|
11
|
+
require_relative "../analysis/reachability/signature_scan"
|
|
12
|
+
require_relative "../analysis/reachability/project_files"
|
|
13
|
+
require_relative "options"
|
|
14
|
+
require_relative "command"
|
|
15
|
+
require_relative "probe_environment"
|
|
16
|
+
|
|
17
|
+
module Rigor
|
|
18
|
+
class CLI
|
|
19
|
+
# ADR-102 — executes `rigor unused`.
|
|
20
|
+
#
|
|
21
|
+
# Reports project constants that nothing reachable references. It is a REPORT, never a diagnostic: the
|
|
22
|
+
# measured precision of this signal is 7.0% on an adjudicated corpus target
|
|
23
|
+
# (`docs/notes/20260813-unused-constant-fp-baseline.md`), so its output is a review queue, not a defect
|
|
24
|
+
# list, and it never enters `rigor check`'s stream at any severity (WD1). Exits 0 whatever it finds.
|
|
25
|
+
class UnusedCommand < Command
|
|
26
|
+
USAGE = "Usage: rigor unused [options] [paths]"
|
|
27
|
+
|
|
28
|
+
# WD7 — the REFERENCE corpus is wider than the ANALYSIS corpus. `.rake` files sit inside `paths:` and
|
|
29
|
+
# reference project constants, but `PathExpansion::RUBY_GLOB` never reads them, which made them pure
|
|
30
|
+
# artifacts on two of three corpus targets. Harvesting references from a file is far cheaper than
|
|
31
|
+
# type-checking it, so the report takes the wider set.
|
|
32
|
+
REFERENCE_GLOB = "**/*.{rb,rake}"
|
|
33
|
+
|
|
34
|
+
# Templates and data files that carry class names as strings. A name here is NOT counted as a reference —
|
|
35
|
+
# a YAML value is far weaker evidence than a constant node, and treating it as proof would silently hide
|
|
36
|
+
# real dead code. It demotes the declaration to `cannot-decide` instead, with the file named, so the
|
|
37
|
+
# reader judges it (ADR-102 WD4).
|
|
38
|
+
TEMPLATE_GLOB = "**/*.{erb,haml,slim,yml,yaml,json}"
|
|
39
|
+
|
|
40
|
+
def run
|
|
41
|
+
options = parse_options
|
|
42
|
+
return CLI::EXIT_USAGE if options == :usage_error
|
|
43
|
+
|
|
44
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
45
|
+
paths = @argv.empty? ? configuration.paths : @argv
|
|
46
|
+
declarations, references, dynamic_uses = scan(paths, configuration)
|
|
47
|
+
references.concat(signature_references(configuration))
|
|
48
|
+
dynamic_uses.concat(template_mentions(declarations))
|
|
49
|
+
|
|
50
|
+
contribution = Analysis::Reachability::PluginRoots.collect(configuration: configuration)
|
|
51
|
+
references.concat(plugin_references(contribution.references))
|
|
52
|
+
graph = Analysis::Reachability::Graph.new(
|
|
53
|
+
declarations: declarations, references: references, dynamic_uses: dynamic_uses,
|
|
54
|
+
root_fqns: root_fqns(declarations, options.fetch(:entry_points)) + contribution.roots,
|
|
55
|
+
foreign: foreign_predicate(configuration)
|
|
56
|
+
)
|
|
57
|
+
emit(graph.report, options, supply: root_supply(contribution.roots, declarations))
|
|
58
|
+
0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def parse_options
|
|
64
|
+
options = { config: nil, format: "text", entry_points: [], limit: nil }
|
|
65
|
+
parser = OptionParser.new do |opts|
|
|
66
|
+
opts.banner = USAGE
|
|
67
|
+
Options.add_config(opts, options)
|
|
68
|
+
opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
|
|
69
|
+
opts.on("--entry-point=GLOB", "Treat declarations in files matching GLOB as roots (repeatable)") do |v|
|
|
70
|
+
options[:entry_points] << v
|
|
71
|
+
end
|
|
72
|
+
opts.on("--limit=N", Integer, "Print at most N candidates (default: all)") { |v| options[:limit] = v }
|
|
73
|
+
# WD5 — refuse rather than silently absorb. A reachability answer is sound only over a full run, and a
|
|
74
|
+
# user who asked for the fast path must not silently get the slow one.
|
|
75
|
+
opts.on("--incremental", "(unsupported)") { options[:incremental] = true }
|
|
76
|
+
end
|
|
77
|
+
parser.parse!(@argv)
|
|
78
|
+
return usage_error(parser) if options[:incremental]
|
|
79
|
+
|
|
80
|
+
options
|
|
81
|
+
rescue OptionParser::ParseError => e
|
|
82
|
+
@err.puts(e.message)
|
|
83
|
+
:usage_error
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def usage_error(_parser)
|
|
87
|
+
@err.puts("rigor unused does not support --incremental: reachability is only sound over a whole-project " \
|
|
88
|
+
"run, so an incremental pass would report constants as unused merely because the files that " \
|
|
89
|
+
"reference them were served from cache. Re-run without --incremental.")
|
|
90
|
+
:usage_error
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Declarations come from the analysed paths; references additionally from the wider corpus (WD7).
|
|
94
|
+
def scan(paths, configuration)
|
|
95
|
+
declaration_files = Analysis::PathExpansion.ruby_files(paths, configuration.exclude_patterns).to_set
|
|
96
|
+
declarations = []
|
|
97
|
+
references = []
|
|
98
|
+
dynamic_uses = []
|
|
99
|
+
(declaration_files + reference_files(paths, configuration)).sort.each do |file|
|
|
100
|
+
result = read_and_scan(file, configuration)
|
|
101
|
+
next if result.nil?
|
|
102
|
+
|
|
103
|
+
declarations.concat(result.declarations) if declaration_files.include?(file)
|
|
104
|
+
references.concat(result.references)
|
|
105
|
+
dynamic_uses.concat(result.dynamic_uses)
|
|
106
|
+
end
|
|
107
|
+
[declarations, references, dynamic_uses]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The reference corpus is the PROJECT, not the analysed paths. A constant declared in `lib/` is commonly
|
|
111
|
+
# referenced from `config/initializers`, a `.rake` task, or a spec — none of which are in `paths:` — and
|
|
112
|
+
# treating those as absent is what manufactured artifacts on two of three corpus targets in #345.
|
|
113
|
+
# Widening the *declaration* set the same way would cancel the gain (measured: +1 / −3 / +2 candidates,
|
|
114
|
+
# ADR-102 WD2), which is exactly why the two corpora are separated rather than both widened.
|
|
115
|
+
def reference_files(_paths, configuration)
|
|
116
|
+
relative = Analysis::Reachability::ProjectFiles.own(Dir.glob(REFERENCE_GLOB, base: Dir.pwd), Dir.pwd)
|
|
117
|
+
absolute = relative.map { |rel| File.expand_path(rel) }
|
|
118
|
+
Analysis::PathExpansion.reject_excluded(absolute, configuration.exclude_patterns).to_set
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def read_and_scan(file, configuration)
|
|
122
|
+
Analysis::Reachability::Scan.call(path: file, source: File.read(file),
|
|
123
|
+
target_ruby: configuration.target_ruby)
|
|
124
|
+
rescue SystemCallError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A glob is matched against the declaration's path AND its path relative to the working directory:
|
|
129
|
+
# `configuration.paths` are expanded to absolute, so a user-written `--entry-point=lib/entry.rb` would
|
|
130
|
+
# otherwise never match anything and silently produce a root set of zero.
|
|
131
|
+
# A constant named only from the project's own `sig/` is referenced — the #345 probe reported exactly this
|
|
132
|
+
# as a false candidate until an RBS-side hook was added, because `Reflection.resolve_constant_type` is
|
|
133
|
+
# source-side only. A signature also DECLARES, though, and {Analysis::Reachability::SignatureScan} is
|
|
134
|
+
# what keeps the two apart (issue #363).
|
|
135
|
+
def signature_references(configuration)
|
|
136
|
+
Array(configuration.signature_paths).flat_map do |dir|
|
|
137
|
+
next [] unless File.directory?(dir)
|
|
138
|
+
|
|
139
|
+
Dir.glob(File.join(dir, "**/*.rbs")).flat_map { |file| Analysis::Reachability::SignatureScan.call(file) }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# #350 — a plugin's `:reachability_references` fact enters the graph exactly where a scanned file-level
|
|
144
|
+
# reference does: `from: nil` (so it seeds) carrying the role the plugin claimed. That is what keeps a
|
|
145
|
+
# class named only by a FactoryBot `class:` string in the `reachable only from tests` section instead of
|
|
146
|
+
# promoting it to production-reachable, which rooting it would have done silently.
|
|
147
|
+
#
|
|
148
|
+
# `path` is a synthetic marker rather than a file: the graph never reads a reference's path (rows are
|
|
149
|
+
# located from the DECLARATION site), and inventing a plausible-looking file path for a fact would be a
|
|
150
|
+
# worse lie than an obviously synthetic one.
|
|
151
|
+
def plugin_references(rows)
|
|
152
|
+
rows.map do |reference|
|
|
153
|
+
Analysis::Reachability::Scan::Reference.new(as_written: reference.name, nesting: [].freeze, from: nil,
|
|
154
|
+
role: reference.role, path: "(plugin)", line: 1)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def template_mentions(declarations)
|
|
159
|
+
names = declarations.map(&:fqn)
|
|
160
|
+
return [] if names.empty?
|
|
161
|
+
|
|
162
|
+
Analysis::Reachability::ProjectFiles.own(Dir.glob(TEMPLATE_GLOB, base: Dir.pwd), Dir.pwd).flat_map do |rel|
|
|
163
|
+
text = File.read(File.expand_path(rel)).scrub
|
|
164
|
+
names.filter_map do |fqn|
|
|
165
|
+
next unless text.include?(fqn)
|
|
166
|
+
|
|
167
|
+
Analysis::Reachability::Scan::DynamicUse.new(name: nil, prefix: fqn, site: nil,
|
|
168
|
+
reason: "named as a string in #{rel}",
|
|
169
|
+
path: rel, line: 1)
|
|
170
|
+
end
|
|
171
|
+
rescue SystemCallError, ArgumentError
|
|
172
|
+
[]
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def root_fqns(declarations, globs)
|
|
177
|
+
return [] if globs.empty?
|
|
178
|
+
|
|
179
|
+
matcher = Analysis::Reachability::ProjectFiles.method(:entry_point_match?)
|
|
180
|
+
cwd = "#{File.expand_path(Dir.pwd)}/"
|
|
181
|
+
declarations.filter_map do |d|
|
|
182
|
+
relative = d.path.delete_prefix(cwd)
|
|
183
|
+
d.fqn if globs.any? { |g| matcher.call(g, d.path) || matcher.call(g, relative) }
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# WD6 — ownership means "declared FIRST here". Reopening a gem or stdlib class registers it as a project
|
|
188
|
+
# declaration, which produced three of redmine's artifacts from a single initializer. A name the bundled
|
|
189
|
+
# (non-project) environment already knows is not ours to call unused. The project's own `sig/` is
|
|
190
|
+
# deliberately excluded from this environment, so a project class that ships a signature stays owned.
|
|
191
|
+
def foreign_predicate(configuration)
|
|
192
|
+
env = Environment.for_project(libraries: configuration.libraries, signature_paths: [])
|
|
193
|
+
->(fqn) { !env.singleton_for_name(fqn).nil? }
|
|
194
|
+
rescue StandardError
|
|
195
|
+
->(_fqn) { false }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# ADR-102 § Consequences — "a root source that OVER-supplies silently hides real dead code, which is
|
|
199
|
+
# worse than one that under-supplies, so each plugin's contribution needs its own corpus check". A
|
|
200
|
+
# supplied root naming a constant the project does not declare is inert in the graph, but it is the
|
|
201
|
+
# observable symptom of a root source drifting away from the code — a renamed controller, an
|
|
202
|
+
# inflection the plugin gets wrong, a convention that stopped holding. Reporting the count makes that
|
|
203
|
+
# drift measurable on a real project instead of invisible.
|
|
204
|
+
Supply = Data.define(:supplied, :unmatched)
|
|
205
|
+
private_constant :Supply
|
|
206
|
+
|
|
207
|
+
def root_supply(plugin_roots, declarations)
|
|
208
|
+
declared = declarations.to_set(&:fqn)
|
|
209
|
+
Supply.new(supplied: plugin_roots.size, unmatched: plugin_roots.count { |fqn| !declared.include?(fqn) })
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def emit(report, options, supply:)
|
|
213
|
+
@out.puts(options.fetch(:format) == "json" ? json(report, options, supply) : text(report, options, supply))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def json(report, options, supply)
|
|
217
|
+
JSON.pretty_generate(
|
|
218
|
+
declared: report.declared, reachable: report.reachable, roots: report.roots, edges: report.edges,
|
|
219
|
+
namespaces: report.namespaces,
|
|
220
|
+
plugin_roots: supply.supplied, plugin_roots_unmatched: supply.unmatched,
|
|
221
|
+
candidates: rows_json(report.candidates, options), test_only: rows_json(report.test_only, options),
|
|
222
|
+
undecidable: limited(report.undecidable, options).map do |u|
|
|
223
|
+
{ name: u.fqn, path: u.path, line: u.line, reason: u.reason }
|
|
224
|
+
end
|
|
225
|
+
)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def rows_json(rows, options)
|
|
229
|
+
limited(rows, options).map { |c| { name: c.fqn, path: c.path, line: c.line } }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def text(report, options, supply)
|
|
233
|
+
lines = ["Reachability", " declared (project-owned): #{report.declared}",
|
|
234
|
+
" roots: #{report.roots}#{plugin_root_note(supply)}",
|
|
235
|
+
" reachable: #{report.reachable}",
|
|
236
|
+
" candidates: #{report.candidates.size}",
|
|
237
|
+
" reachable only from tests: #{report.test_only.size}",
|
|
238
|
+
" cannot decide: #{report.undecidable.size}",
|
|
239
|
+
" namespace-only (excluded): #{report.namespaces}"]
|
|
240
|
+
lines.concat(section("Candidates — nothing reachable references these", report.candidates, options))
|
|
241
|
+
lines.concat(section("Reachable only from test code — live test, dead production path",
|
|
242
|
+
report.test_only, options))
|
|
243
|
+
lines.concat(undecidable_section(report.undecidable, options))
|
|
244
|
+
lines << ""
|
|
245
|
+
lines << "These are CANDIDATES, not findings. The measured precision of this report on an adjudicated"
|
|
246
|
+
lines << "corpus target is 7% — every row needs a human to confirm it. Roots a framework supplies come"
|
|
247
|
+
lines << "from the project's plugins, so a framework Rigor has no plugin for still reads as an upper bound."
|
|
248
|
+
lines.join("\n")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# `(N from plugins, M matched nothing)` — omitted entirely when no plugin contributed, so a
|
|
252
|
+
# non-Rails project's report is unchanged. A non-zero `matched nothing` is the over-supply signal
|
|
253
|
+
# described on {#root_supply}: those roots claim constants this project does not declare.
|
|
254
|
+
def plugin_root_note(supply)
|
|
255
|
+
return "" if supply.supplied.zero?
|
|
256
|
+
|
|
257
|
+
" (#{supply.supplied} from plugins, #{supply.unmatched} matched no declaration)"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def undecidable_section(rows, options)
|
|
261
|
+
return [] if rows.empty?
|
|
262
|
+
|
|
263
|
+
out = ["", "Cannot decide — something can name these at runtime (#{rows.size})"]
|
|
264
|
+
limited(rows, options).each_with_index do |u, i|
|
|
265
|
+
out << format(" %<n>3d %<name>-52s %<path>s:%<line>d", n: i + 1, name: u.fqn, path: u.path,
|
|
266
|
+
line: u.line)
|
|
267
|
+
out << " #{u.reason}"
|
|
268
|
+
end
|
|
269
|
+
out
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def section(title, rows, options)
|
|
273
|
+
return [] if rows.empty?
|
|
274
|
+
|
|
275
|
+
out = ["", "#{title} (#{rows.size})"]
|
|
276
|
+
limited(rows, options).each_with_index do |c, i|
|
|
277
|
+
out << format(" %<n>3d %<name>-52s %<path>s:%<line>d", n: i + 1, name: c.fqn, path: c.path, line: c.line)
|
|
278
|
+
end
|
|
279
|
+
out
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def limited(candidates, options)
|
|
283
|
+
limit = options.fetch(:limit)
|
|
284
|
+
limit ? candidates.first(limit) : candidates
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -37,6 +37,7 @@ module Rigor
|
|
|
37
37
|
"type-of" => :run_type_of,
|
|
38
38
|
"trace" => :run_trace,
|
|
39
39
|
"type-scan" => :run_type_scan,
|
|
40
|
+
"effects" => :run_effects,
|
|
40
41
|
"explain" => :run_explain,
|
|
41
42
|
"diff" => :run_diff,
|
|
42
43
|
"sig-gen" => :run_sig_gen,
|
|
@@ -44,6 +45,7 @@ module Rigor
|
|
|
44
45
|
"mcp" => :run_mcp,
|
|
45
46
|
"baseline" => :run_baseline,
|
|
46
47
|
"triage" => :run_triage,
|
|
48
|
+
"unused" => :run_unused,
|
|
47
49
|
"coverage" => :run_coverage,
|
|
48
50
|
"plugins" => :run_plugins,
|
|
49
51
|
"plugin" => :run_plugin,
|
|
@@ -82,6 +84,17 @@ module Rigor
|
|
|
82
84
|
rescue OptionParser::ParseError => e
|
|
83
85
|
@err.puts(e.message)
|
|
84
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
|
|
85
98
|
end
|
|
86
99
|
|
|
87
100
|
private
|
|
@@ -158,7 +171,7 @@ module Rigor
|
|
|
158
171
|
# - paths: directories scanned by `rigor check` and
|
|
159
172
|
# `rigor type-scan` when no path is given.
|
|
160
173
|
# - plugins: opt-in list of plugin gem names to load.
|
|
161
|
-
# See https://github.com/rigortype/rigor/tree/
|
|
174
|
+
# See https://github.com/rigortype/rigor/tree/master/plugins
|
|
162
175
|
# for production plugins (rigor-activerecord, rigor-sorbet, …).
|
|
163
176
|
# - disable: list of `rigor check` rule identifiers to
|
|
164
177
|
# silence project-wide. The shipped rules are
|
|
@@ -220,6 +233,12 @@ module Rigor
|
|
|
220
233
|
TypeScanCommand.new(argv: @argv, out: @out, err: @err).run
|
|
221
234
|
end
|
|
222
235
|
|
|
236
|
+
def run_effects
|
|
237
|
+
require_relative "cli/effects_command"
|
|
238
|
+
|
|
239
|
+
EffectsCommand.new(argv: @argv, out: @out, err: @err).run
|
|
240
|
+
end
|
|
241
|
+
|
|
223
242
|
def run_explain
|
|
224
243
|
require_relative "cli/explain_command"
|
|
225
244
|
|
|
@@ -262,6 +281,12 @@ module Rigor
|
|
|
262
281
|
CLI::TriageCommand.new(argv: @argv, out: @out, err: @err).run
|
|
263
282
|
end
|
|
264
283
|
|
|
284
|
+
def run_unused
|
|
285
|
+
require_relative "cli/unused_command"
|
|
286
|
+
|
|
287
|
+
CLI::UnusedCommand.new(argv: @argv, out: @out, err: @err).run
|
|
288
|
+
end
|
|
289
|
+
|
|
265
290
|
def run_coverage
|
|
266
291
|
require_relative "cli/coverage_command"
|
|
267
292
|
|
|
@@ -341,6 +366,8 @@ module Rigor
|
|
|
341
366
|
type-of Print the inferred type at FILE:LINE:COL
|
|
342
367
|
trace Replay how the engine typed FILE as a terminal animation
|
|
343
368
|
type-scan Report Scope#type_of coverage across PATHs
|
|
369
|
+
effects Report each method's effect labels, and the committed effect snapshot
|
|
370
|
+
(ADR-103, opt-in; effects update/check/diff/explain)
|
|
344
371
|
explain Print the description of one or all CheckRules
|
|
345
372
|
diff Compare current diagnostics to a saved baseline JSON
|
|
346
373
|
sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
|
|
@@ -63,6 +63,19 @@ module Rigor
|
|
|
63
63
|
# ADR-100 — a new required diagnostic (ADR-50 WD1), so it is `:off` in every shipped profile and
|
|
64
64
|
# reaches a user only through the `use-of-void-value` bleeding-edge feature, which overrides this
|
|
65
65
|
# to `:warning`.
|
|
66
|
+
# ADR-103 WD8 / #383 — opt-in twice over (the `effects:` block, then the author's own
|
|
67
|
+
# envelope directive), so it is never unsolicited noise and needs no bleeding-edge gate:
|
|
68
|
+
# `:warning` even under lenient, `:error` under strict.
|
|
69
|
+
"effect.envelope-exceeded" => :warning,
|
|
70
|
+
# ADR-103 WD14 / #386 — the inherited-bound reading rides its sibling's severities exactly:
|
|
71
|
+
# both-sides-authored (someone wrote the ancestor's envelope), and as strict as proven.
|
|
72
|
+
"effect.liskov-widened" => :warning,
|
|
73
|
+
# ADR-103 WD14 — `unknown-label` info / info / warning. It reports that a bound stopped
|
|
74
|
+
# bounding, never that code is wrong, so even `strict` stops at `:warning`.
|
|
75
|
+
"effect.unknown-label" => :info,
|
|
76
|
+
# The residual is advisory in every profile: it says a declaration is inert, and the fix is
|
|
77
|
+
# a config edit the author may deliberately not want.
|
|
78
|
+
"effect.annotations-unchecked" => :info,
|
|
66
79
|
"static.value-use.void" => :off,
|
|
67
80
|
# Opt-in author assertion: you only see it if you wrote a
|
|
68
81
|
# `conforms-to` directive, so it stays a :warning even in
|
|
@@ -101,6 +114,10 @@ module Rigor
|
|
|
101
114
|
"suppression.empty" => :warning,
|
|
102
115
|
"suppression.unknown-marker" => :warning,
|
|
103
116
|
"static.value-use.void" => :off,
|
|
117
|
+
"effect.envelope-exceeded" => :warning,
|
|
118
|
+
"effect.liskov-widened" => :warning,
|
|
119
|
+
"effect.unknown-label" => :info,
|
|
120
|
+
"effect.annotations-unchecked" => :info,
|
|
104
121
|
"rbs_extended.unsatisfied-conformance" => :warning
|
|
105
122
|
}.freeze,
|
|
106
123
|
strict: {
|
|
@@ -135,6 +152,12 @@ module Rigor
|
|
|
135
152
|
"suppression.unknown-marker" => :warning,
|
|
136
153
|
# `:off` even under strict: the gate is `bleeding_edge:`, not the profile (ADR-50 WD1 / ADR-100).
|
|
137
154
|
"static.value-use.void" => :off,
|
|
155
|
+
"effect.envelope-exceeded" => :error,
|
|
156
|
+
"effect.liskov-widened" => :error,
|
|
157
|
+
"effect.unknown-label" => :warning,
|
|
158
|
+
# `:info` even under strict: a residual that failed a build would punish the project for
|
|
159
|
+
# carrying an annotation it has not opted into checking, which is the opposite of the point.
|
|
160
|
+
"effect.annotations-unchecked" => :info,
|
|
138
161
|
"rbs_extended.unsatisfied-conformance" => :error
|
|
139
162
|
}.freeze
|
|
140
163
|
}.freeze
|