rigortype 0.1.1 → 0.1.3
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 +10 -0
- data/data/builtins/ruby_core/range.yml +6 -4
- data/data/builtins/ruby_core/string.yml +15 -10
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +116 -0
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +123 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +118 -0
- data/lib/rigor/analysis/check_rules.rb +346 -18
- data/lib/rigor/analysis/dependency_source_inference/builder.rb +87 -0
- data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +72 -0
- data/lib/rigor/analysis/dependency_source_inference/index.rb +110 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +200 -0
- data/lib/rigor/analysis/dependency_source_inference.rb +37 -0
- data/lib/rigor/analysis/rule_catalog.rb +343 -0
- data/lib/rigor/analysis/runner.rb +96 -6
- data/lib/rigor/cache/descriptor.rb +58 -5
- data/lib/rigor/cli/diff_command.rb +169 -0
- data/lib/rigor/cli/explain_command.rb +129 -0
- data/lib/rigor/cli.rb +18 -1
- data/lib/rigor/configuration/dependencies.rb +235 -0
- data/lib/rigor/configuration/severity_profile.rb +18 -3
- data/lib/rigor/configuration.rb +53 -13
- data/lib/rigor/environment.rb +16 -4
- data/lib/rigor/flow_contribution/merger.rb +4 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +104 -12
- data/lib/rigor/inference/method_dispatcher.rb +87 -0
- data/lib/rigor/inference/scope_indexer.rb +171 -2
- data/lib/rigor/inference/statement_evaluator.rb +65 -1
- data/lib/rigor/plugin/io_boundary.rb +92 -19
- data/lib/rigor/plugin/manifest.rb +26 -5
- data/lib/rigor/plugin/trust_policy.rb +30 -7
- data/lib/rigor/scope.rb +30 -5
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/scope.rbs +3 -0
- metadata +13 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optionparser"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
class CLI
|
|
8
|
+
# Executes `rigor diff <baseline.json> [paths...]`. Compares
|
|
9
|
+
# the current `rigor check` diagnostics against a saved
|
|
10
|
+
# baseline JSON (the output of a previous `rigor check
|
|
11
|
+
# --format=json` run) and prints the delta:
|
|
12
|
+
#
|
|
13
|
+
# - **new** — diagnostics in the current run that were not
|
|
14
|
+
# in the baseline (typically a regression introduced in
|
|
15
|
+
# this PR).
|
|
16
|
+
# - **fixed** — diagnostics in the baseline that no longer
|
|
17
|
+
# appear in the current run (typically progress).
|
|
18
|
+
#
|
|
19
|
+
# Identity for matching is the tuple
|
|
20
|
+
# `(path, line, column, rule, source_family, message)`.
|
|
21
|
+
# An edit that moves a diagnostic to a new line surfaces as
|
|
22
|
+
# one fixed + one new pair, which lines up with the user's
|
|
23
|
+
# mental model of "you changed the line, the analyzer's
|
|
24
|
+
# position changed too."
|
|
25
|
+
#
|
|
26
|
+
# CI usage: commit a `rigor.baseline.json` produced once
|
|
27
|
+
# with `rigor check --format=json > rigor.baseline.json`,
|
|
28
|
+
# then run `rigor diff rigor.baseline.json` in CI. Exit code
|
|
29
|
+
# is `1` when any new diagnostic appears, `0` otherwise —
|
|
30
|
+
# so adding new errors fails CI but legacy errors recorded
|
|
31
|
+
# in the baseline don't.
|
|
32
|
+
class DiffCommand # rubocop:disable Metrics/ClassLength
|
|
33
|
+
USAGE = "Usage: rigor diff [options] <baseline.json> [paths...]"
|
|
34
|
+
|
|
35
|
+
def initialize(argv:, out:, err:)
|
|
36
|
+
@argv = argv
|
|
37
|
+
@out = out
|
|
38
|
+
@err = err
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Integer] CLI exit status.
|
|
42
|
+
def run
|
|
43
|
+
options = parse_options
|
|
44
|
+
|
|
45
|
+
baseline_path = @argv.shift
|
|
46
|
+
if baseline_path.nil?
|
|
47
|
+
@err.puts(USAGE)
|
|
48
|
+
return CLI::EXIT_USAGE
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
baseline = load_diagnostics(baseline_path)
|
|
52
|
+
current = options.fetch(:current_path) ? load_diagnostics(options.fetch(:current_path)) : run_current(options)
|
|
53
|
+
return CLI::EXIT_USAGE if baseline.nil? || current.nil?
|
|
54
|
+
|
|
55
|
+
diff = compute_diff(baseline, current)
|
|
56
|
+
write_diff(
|
|
57
|
+
diff, options.fetch(:format),
|
|
58
|
+
baseline_path: baseline_path,
|
|
59
|
+
baseline_count: baseline.size,
|
|
60
|
+
current_count: current.size
|
|
61
|
+
)
|
|
62
|
+
diff[:new].any? ? 1 : 0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def parse_options
|
|
68
|
+
options = { format: "text", current_path: nil, config: nil }
|
|
69
|
+
OptionParser.new do |opt|
|
|
70
|
+
opt.banner = USAGE
|
|
71
|
+
opt.on("--format=FORMAT", %w[text json], "Output format (text | json). Default: text.") do |fmt|
|
|
72
|
+
options[:format] = fmt
|
|
73
|
+
end
|
|
74
|
+
opt.on("--current=PATH", "Compare to the saved current JSON instead of running `rigor check`.") do |path|
|
|
75
|
+
options[:current_path] = path
|
|
76
|
+
end
|
|
77
|
+
opt.on("--config=PATH", "Path to .rigor.yml. Forwarded to the implicit `rigor check` run.") do |path|
|
|
78
|
+
options[:config] = path
|
|
79
|
+
end
|
|
80
|
+
end.parse!(@argv)
|
|
81
|
+
options
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Runs `rigor check` against the remaining argv (or the
|
|
85
|
+
# configured paths) and returns the diagnostics array.
|
|
86
|
+
# Reuses the analyzer + configuration plumbing the
|
|
87
|
+
# check-command path uses.
|
|
88
|
+
def run_current(options)
|
|
89
|
+
require_relative "../analysis/runner"
|
|
90
|
+
require_relative "../configuration"
|
|
91
|
+
|
|
92
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
93
|
+
paths = @argv.empty? ? configuration.paths : @argv
|
|
94
|
+
result = Analysis::Runner.new(configuration: configuration).run(paths)
|
|
95
|
+
result.diagnostics.map(&:to_h)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def load_diagnostics(path)
|
|
99
|
+
unless File.file?(path)
|
|
100
|
+
@err.puts("Baseline file not found: #{path}")
|
|
101
|
+
return nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
payload = JSON.parse(File.read(path))
|
|
105
|
+
payload.is_a?(Hash) ? Array(payload["diagnostics"]) : Array(payload)
|
|
106
|
+
rescue JSON::ParserError => e
|
|
107
|
+
@err.puts("Invalid JSON in #{path}: #{e.message}")
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
KEY_FIELDS = %w[path line column rule source_family message].freeze
|
|
112
|
+
private_constant :KEY_FIELDS
|
|
113
|
+
|
|
114
|
+
def compute_diff(baseline, current)
|
|
115
|
+
baseline_keys = baseline.to_set { |d| identity_for(d) }
|
|
116
|
+
current_keys = current.to_set { |d| identity_for(d) }
|
|
117
|
+
|
|
118
|
+
new_diags = current.reject { |d| baseline_keys.include?(identity_for(d)) }
|
|
119
|
+
fixed = baseline.reject { |d| current_keys.include?(identity_for(d)) }
|
|
120
|
+
{ new: new_diags, fixed: fixed }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def identity_for(diagnostic)
|
|
124
|
+
KEY_FIELDS.map { |k| diagnostic[k] }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def write_diff(diff, format, baseline_path:, baseline_count:, current_count:)
|
|
128
|
+
case format
|
|
129
|
+
when "json"
|
|
130
|
+
write_diff_json(diff, baseline_path, baseline_count, current_count)
|
|
131
|
+
else
|
|
132
|
+
write_diff_text(diff, baseline_path, baseline_count, current_count)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def write_diff_json(diff, baseline_path, baseline_count, current_count)
|
|
137
|
+
@out.puts(JSON.pretty_generate(
|
|
138
|
+
"baseline" => baseline_path,
|
|
139
|
+
"baseline_count" => baseline_count,
|
|
140
|
+
"current_count" => current_count,
|
|
141
|
+
"new" => diff[:new],
|
|
142
|
+
"fixed" => diff[:fixed]
|
|
143
|
+
))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def write_diff_text(diff, baseline_path, baseline_count, current_count)
|
|
147
|
+
@out.puts("# diff against #{baseline_path} (#{baseline_count} baseline / #{current_count} current)")
|
|
148
|
+
diff[:new].each { |d| @out.puts("+ NEW #{render_diagnostic(d)}") }
|
|
149
|
+
diff[:fixed].each { |d| @out.puts("- FIXED #{render_diagnostic(d)}") }
|
|
150
|
+
@out.puts("")
|
|
151
|
+
@out.puts("#{diff[:new].size} new, #{diff[:fixed].size} fixed")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def render_diagnostic(diagnostic)
|
|
155
|
+
rule = qualified_rule_for(diagnostic)
|
|
156
|
+
position = "#{diagnostic['path']}:#{diagnostic['line']}:#{diagnostic['column']}"
|
|
157
|
+
"#{position} [#{rule}] #{diagnostic['message']}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def qualified_rule_for(diagnostic)
|
|
161
|
+
family = diagnostic["source_family"]
|
|
162
|
+
rule = diagnostic["rule"]
|
|
163
|
+
return rule if family.nil? || family == "" || family == "builtin"
|
|
164
|
+
|
|
165
|
+
"#{family}.#{rule}"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optionparser"
|
|
5
|
+
|
|
6
|
+
require_relative "../analysis/rule_catalog"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
class CLI
|
|
10
|
+
# Executes `rigor explain <rule>`. Prints the catalog entry for
|
|
11
|
+
# one canonical rule id, a legacy alias, or a family wildcard
|
|
12
|
+
# (`call`, `flow`, `assert`, `dump`, `def`).
|
|
13
|
+
#
|
|
14
|
+
# Without arguments lists every rule's id and one-line summary.
|
|
15
|
+
#
|
|
16
|
+
# The command is read-only: no parser, no analyzer, no I/O
|
|
17
|
+
# beyond the rendered catalog. Useful when a user sees a
|
|
18
|
+
# diagnostic in the editor and wants to know what the rule
|
|
19
|
+
# means without leaving the terminal.
|
|
20
|
+
class ExplainCommand
|
|
21
|
+
USAGE = "Usage: rigor explain [options] [<rule>]"
|
|
22
|
+
|
|
23
|
+
def initialize(argv:, out:, err:)
|
|
24
|
+
@argv = argv
|
|
25
|
+
@out = out
|
|
26
|
+
@err = err
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Integer] CLI exit status.
|
|
30
|
+
def run
|
|
31
|
+
options = parse_options
|
|
32
|
+
|
|
33
|
+
if @argv.empty?
|
|
34
|
+
render_index(options.fetch(:format))
|
|
35
|
+
return 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
token = @argv.shift
|
|
39
|
+
entries = Analysis::RuleCatalog.resolve(token)
|
|
40
|
+
if entries.empty?
|
|
41
|
+
@err.puts("Unknown rule: #{token}")
|
|
42
|
+
@err.puts("Run `rigor explain` with no arguments to list every rule.")
|
|
43
|
+
return CLI::EXIT_USAGE
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
render_entries(entries, options.fetch(:format))
|
|
47
|
+
0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def parse_options
|
|
53
|
+
options = { format: "text" }
|
|
54
|
+
OptionParser.new do |opt|
|
|
55
|
+
opt.banner = USAGE
|
|
56
|
+
opt.on("--format=FORMAT", %w[text json], "Output format (text | json). Default: text.") do |fmt|
|
|
57
|
+
options[:format] = fmt
|
|
58
|
+
end
|
|
59
|
+
end.parse!(@argv)
|
|
60
|
+
options
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def render_index(format)
|
|
64
|
+
case format
|
|
65
|
+
when "json" then @out.puts(JSON.pretty_generate(Analysis::RuleCatalog.all.map(&:to_h)))
|
|
66
|
+
else render_index_text
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render_index_text
|
|
71
|
+
@out.puts("Available rules:")
|
|
72
|
+
@out.puts("")
|
|
73
|
+
Analysis::RuleCatalog.all.each do |entry|
|
|
74
|
+
@out.puts(" #{entry.id.ljust(33)} #{entry.summary}")
|
|
75
|
+
end
|
|
76
|
+
@out.puts("")
|
|
77
|
+
@out.puts("Run `rigor explain <rule>` for the full description.")
|
|
78
|
+
@out.puts("Family wildcards (`call`, `flow`, `assert`, `dump`, `def`) print every rule under that prefix.")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def render_entries(entries, format)
|
|
82
|
+
case format
|
|
83
|
+
when "json" then @out.puts(JSON.pretty_generate(entries.map(&:to_h)))
|
|
84
|
+
else
|
|
85
|
+
entries.each_with_index do |entry, index|
|
|
86
|
+
@out.puts("") if index.positive?
|
|
87
|
+
render_entry_text(entry)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def render_entry_text(entry)
|
|
93
|
+
@out.puts(entry.id)
|
|
94
|
+
@out.puts("=" * entry.id.length)
|
|
95
|
+
@out.puts("")
|
|
96
|
+
@out.puts(entry.summary)
|
|
97
|
+
@out.puts("")
|
|
98
|
+
render_aliases(entry)
|
|
99
|
+
render_severity(entry)
|
|
100
|
+
render_section("Fires when:", entry.fires_when)
|
|
101
|
+
render_section("Does not fire when:", entry.does_not_fire_when)
|
|
102
|
+
@out.puts("Suppression: #{entry.suppression}")
|
|
103
|
+
@out.puts("Since: rigor #{entry.since}")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def render_aliases(entry)
|
|
107
|
+
return if entry.aliases.empty?
|
|
108
|
+
|
|
109
|
+
@out.puts("Legacy aliases: #{entry.aliases.join(', ')}")
|
|
110
|
+
@out.puts("")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def render_severity(entry)
|
|
114
|
+
@out.puts("Authored severity: :#{entry.severity_authored}")
|
|
115
|
+
profile_table = entry.severity_by_profile.map { |profile, sev| "#{profile} → :#{sev}" }.join(", ")
|
|
116
|
+
@out.puts("Severity by profile: #{profile_table}")
|
|
117
|
+
@out.puts("")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def render_section(heading, items)
|
|
121
|
+
return if items.empty?
|
|
122
|
+
|
|
123
|
+
@out.puts(heading)
|
|
124
|
+
items.each { |item| @out.puts(" - #{item}") }
|
|
125
|
+
@out.puts("")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -22,7 +22,9 @@ module Rigor
|
|
|
22
22
|
"check" => :run_check,
|
|
23
23
|
"init" => :run_init,
|
|
24
24
|
"type-of" => :run_type_of,
|
|
25
|
-
"type-scan" => :run_type_scan
|
|
25
|
+
"type-scan" => :run_type_scan,
|
|
26
|
+
"explain" => :run_explain,
|
|
27
|
+
"diff" => :run_diff
|
|
26
28
|
}.freeze
|
|
27
29
|
|
|
28
30
|
def self.start(argv = ARGV, out: $stdout, err: $stderr)
|
|
@@ -207,6 +209,7 @@ module Rigor
|
|
|
207
209
|
# most likely to want to edit.
|
|
208
210
|
def init_template
|
|
209
211
|
<<~YAML
|
|
212
|
+
# yaml-language-server: $schema=https://github.com/zenwerk/rigor/raw/master/schemas/rigor-config.schema.json
|
|
210
213
|
# Rigor configuration. See docs/CURRENT_WORK.md for the
|
|
211
214
|
# full set of features the analyzer ships in this preview.
|
|
212
215
|
#
|
|
@@ -264,6 +267,18 @@ module Rigor
|
|
|
264
267
|
TypeScanCommand.new(argv: @argv, out: @out, err: @err).run
|
|
265
268
|
end
|
|
266
269
|
|
|
270
|
+
def run_explain
|
|
271
|
+
require_relative "cli/explain_command"
|
|
272
|
+
|
|
273
|
+
ExplainCommand.new(argv: @argv, out: @out, err: @err).run
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def run_diff
|
|
277
|
+
require_relative "cli/diff_command"
|
|
278
|
+
|
|
279
|
+
DiffCommand.new(argv: @argv, out: @out, err: @err).run
|
|
280
|
+
end
|
|
281
|
+
|
|
267
282
|
def write_result(result, format)
|
|
268
283
|
case format
|
|
269
284
|
when "json"
|
|
@@ -301,6 +316,8 @@ module Rigor
|
|
|
301
316
|
init Create a starter .rigor.yml
|
|
302
317
|
type-of Print the inferred type at FILE:LINE:COL
|
|
303
318
|
type-scan Report Scope#type_of coverage across PATHs
|
|
319
|
+
explain Print the description of one or all CheckRules
|
|
320
|
+
diff Compare current diagnostics to a saved baseline JSON
|
|
304
321
|
version Print the Rigor version
|
|
305
322
|
help Print this help
|
|
306
323
|
HELP
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
class Configuration
|
|
5
|
+
# Parsed `dependencies:` section of `.rigor.yml`. Per
|
|
6
|
+
# [ADR-10](../../../docs/adr/10-dependency-source-inference.md),
|
|
7
|
+
# the only nested key today is `source_inference:`, listing
|
|
8
|
+
# gems whose Ruby implementation Rigor MAY walk during
|
|
9
|
+
# inference instead of degrading to `Dynamic[top]` at the
|
|
10
|
+
# dependency boundary.
|
|
11
|
+
#
|
|
12
|
+
# Slice 1 lands the parser only — `Configuration#dependencies`
|
|
13
|
+
# is read, but no analyzer machinery consumes it yet. Slice 2
|
|
14
|
+
# wires `Analysis::DependencySourceInference` against this
|
|
15
|
+
# value object.
|
|
16
|
+
class Dependencies # rubocop:disable Metrics/ClassLength
|
|
17
|
+
# Walking modes per
|
|
18
|
+
# [ADR-10 § "Decision"](../../../docs/adr/10-dependency-source-inference.md#decision).
|
|
19
|
+
VALID_MODES = %i[disabled when_missing full].freeze
|
|
20
|
+
|
|
21
|
+
# Default `roots:` for an entry that does not supply one.
|
|
22
|
+
# The hard-excluded directories (`spec/` / `test/` / `bin/`
|
|
23
|
+
# / C extensions) are enforced by the walker, not the
|
|
24
|
+
# parser — see ADR-10 § "Hard exclusions".
|
|
25
|
+
DEFAULT_ROOTS = %w[lib].freeze
|
|
26
|
+
|
|
27
|
+
# Default per-gem catalog cap. ADR-10 slice 4 picks
|
|
28
|
+
# 5000 method definitions: it covers Rack (~1500),
|
|
29
|
+
# Faraday (~500), Sidekiq (~800) and other realistic
|
|
30
|
+
# opt-in targets, while still surfacing a diagnostic for
|
|
31
|
+
# ActiveSupport-class libraries (~10 000+ methods) where
|
|
32
|
+
# the user should ship RBS or de-list the gem instead.
|
|
33
|
+
DEFAULT_BUDGET_PER_GEM = 5000
|
|
34
|
+
|
|
35
|
+
# Range bounds per ADR-10 § "Budget interaction"
|
|
36
|
+
# ("range 0.25× – 4×"). Configured against the default,
|
|
37
|
+
# this lands at 1250 – 20 000.
|
|
38
|
+
MIN_BUDGET_PER_GEM = (DEFAULT_BUDGET_PER_GEM * 0.25).to_i
|
|
39
|
+
MAX_BUDGET_PER_GEM = (DEFAULT_BUDGET_PER_GEM * 4).to_i
|
|
40
|
+
|
|
41
|
+
# ADR-10 5b — budget-overrun strategy enum.
|
|
42
|
+
#
|
|
43
|
+
# - `:walker_cap` (default): the (α) semantics. The
|
|
44
|
+
# walker stops harvesting at the cap; methods past the
|
|
45
|
+
# cap fall through to the existing user-class fallback
|
|
46
|
+
# path. Existing v0.1.3 behaviour.
|
|
47
|
+
# - `:dependency_silence`: the (β) semantics. Same
|
|
48
|
+
# walker behaviour, but the dispatcher additionally
|
|
49
|
+
# consults `Index#class_to_gem` after a catalog miss.
|
|
50
|
+
# When the receiver's class belongs to a budget-
|
|
51
|
+
# exceeded gem, the call resolves to `Dynamic[top]`
|
|
52
|
+
# rather than falling through to user-class fallback.
|
|
53
|
+
# This silences `call.undefined-method` for unrecorded
|
|
54
|
+
# methods at the cost of weaker static checking on
|
|
55
|
+
# that gem's surface.
|
|
56
|
+
VALID_BUDGET_OVERRUN_STRATEGIES = %i[walker_cap dependency_silence].freeze
|
|
57
|
+
DEFAULT_BUDGET_OVERRUN_STRATEGY = :walker_cap
|
|
58
|
+
|
|
59
|
+
# Frozen value object describing a single per-gem opt-in.
|
|
60
|
+
# `gem:` is the gem name (matched against the bundle at
|
|
61
|
+
# walk time); `mode:` is one of {VALID_MODES}; `roots:` is
|
|
62
|
+
# the list of subdirectories within the gem's installation
|
|
63
|
+
# directory to walk (defaults to `["lib"]`).
|
|
64
|
+
Entry = Data.define(:gem, :mode, :roots) do
|
|
65
|
+
def disabled? = mode == :disabled
|
|
66
|
+
def when_missing? = mode == :when_missing
|
|
67
|
+
def full? = mode == :full
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
attr_reader :source_inference, :budget_per_gem, :budget_overrun_strategy, :warnings
|
|
71
|
+
|
|
72
|
+
# Parse the YAML-shaped `dependencies:` value into a
|
|
73
|
+
# frozen {Dependencies}. Accepts `nil` / `{}` / a Hash with
|
|
74
|
+
# `source_inference:` and / or `budget_per_gem:` /
|
|
75
|
+
# `budget_overrun_strategy:` present.
|
|
76
|
+
def self.from_h(data)
|
|
77
|
+
return new([]) if data.nil?
|
|
78
|
+
raise ArgumentError, "dependencies: must be a Hash, got #{data.inspect}" unless data.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
raw_entries = Array(data["source_inference"]).map { |raw| coerce_entry(raw) }
|
|
81
|
+
entries, warnings = dedupe_entries(raw_entries)
|
|
82
|
+
budget = coerce_budget_per_gem(data.fetch("budget_per_gem", DEFAULT_BUDGET_PER_GEM))
|
|
83
|
+
strategy = coerce_budget_overrun_strategy(
|
|
84
|
+
data.fetch("budget_overrun_strategy", DEFAULT_BUDGET_OVERRUN_STRATEGY)
|
|
85
|
+
)
|
|
86
|
+
new(entries, budget, warnings, strategy)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def initialize(source_inference, budget_per_gem = DEFAULT_BUDGET_PER_GEM,
|
|
90
|
+
warnings = [], budget_overrun_strategy = DEFAULT_BUDGET_OVERRUN_STRATEGY)
|
|
91
|
+
@source_inference = source_inference.freeze
|
|
92
|
+
@budget_per_gem = budget_per_gem
|
|
93
|
+
@warnings = warnings.freeze
|
|
94
|
+
@budget_overrun_strategy = budget_overrun_strategy
|
|
95
|
+
freeze
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def to_h
|
|
99
|
+
{
|
|
100
|
+
"source_inference" => @source_inference.map do |entry|
|
|
101
|
+
{
|
|
102
|
+
"gem" => entry.gem,
|
|
103
|
+
"mode" => entry.mode.to_s,
|
|
104
|
+
"roots" => entry.roots
|
|
105
|
+
}
|
|
106
|
+
end,
|
|
107
|
+
"budget_per_gem" => @budget_per_gem,
|
|
108
|
+
"budget_overrun_strategy" => @budget_overrun_strategy.to_s
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def empty? = @source_inference.empty?
|
|
113
|
+
|
|
114
|
+
class << self
|
|
115
|
+
# ADR-10 § "config-conflict diagnostic" — merges a
|
|
116
|
+
# potentially-duplicated entry list (the `includes:`
|
|
117
|
+
# chain produces concatenated arrays via
|
|
118
|
+
# `Configuration.deep_merge`'s special-case for
|
|
119
|
+
# `dependencies.source_inference`) into a single
|
|
120
|
+
# canonical entry per gem name. The merge rules:
|
|
121
|
+
#
|
|
122
|
+
# - Same gem, same all fields → idempotent collapse
|
|
123
|
+
# (no warning).
|
|
124
|
+
# - Same gem, different `mode:` → keep the LAST entry
|
|
125
|
+
# (matches existing right-wins semantics elsewhere)
|
|
126
|
+
# AND emit a `:warning` so the user knows their
|
|
127
|
+
# `includes:` chain is ambiguous.
|
|
128
|
+
# - Same gem, different `roots:` → union the roots
|
|
129
|
+
# silently (no warning). The walker is happy to
|
|
130
|
+
# visit the union.
|
|
131
|
+
#
|
|
132
|
+
# Returns `[entries, warnings]` so the caller can
|
|
133
|
+
# plumb the warning list through to the Runner for
|
|
134
|
+
# diagnostic emission.
|
|
135
|
+
def dedupe_entries(entries)
|
|
136
|
+
warnings = []
|
|
137
|
+
by_gem = {}
|
|
138
|
+
entries.each do |entry|
|
|
139
|
+
existing = by_gem[entry.gem]
|
|
140
|
+
by_gem[entry.gem] = if existing.nil?
|
|
141
|
+
entry
|
|
142
|
+
else
|
|
143
|
+
merge_entry_pair(existing, entry, warnings)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
[by_gem.values, warnings]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def merge_entry_pair(existing, incoming, warnings)
|
|
150
|
+
if existing.mode != incoming.mode
|
|
151
|
+
warnings << "dependencies.source_inference[].gem #{incoming.gem.inspect} declared with " \
|
|
152
|
+
"conflicting modes (#{existing.mode.inspect} vs #{incoming.mode.inspect}); " \
|
|
153
|
+
"the later (#{incoming.mode.inspect}) wins."
|
|
154
|
+
end
|
|
155
|
+
merged_roots = (existing.roots + incoming.roots).uniq.freeze
|
|
156
|
+
Entry.new(gem: incoming.gem, mode: incoming.mode, roots: merged_roots)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def coerce_entry(raw)
|
|
162
|
+
unless raw.is_a?(Hash)
|
|
163
|
+
raise ArgumentError,
|
|
164
|
+
"dependencies.source_inference[] entry must be a Hash, got #{raw.inspect}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
Entry.new(
|
|
168
|
+
gem: coerce_gem(raw["gem"]),
|
|
169
|
+
mode: coerce_mode(raw["mode"]),
|
|
170
|
+
roots: coerce_roots(raw)
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def coerce_gem(value)
|
|
175
|
+
unless value.is_a?(String) && !value.empty?
|
|
176
|
+
raise ArgumentError,
|
|
177
|
+
"dependencies.source_inference[].gem must be a non-empty String, got #{value.inspect}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
value.dup.freeze
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def coerce_mode(value)
|
|
184
|
+
mode = (value || "when_missing").to_sym
|
|
185
|
+
return mode if VALID_MODES.include?(mode)
|
|
186
|
+
|
|
187
|
+
raise ArgumentError,
|
|
188
|
+
"dependencies.source_inference[].mode must be one of " \
|
|
189
|
+
"#{VALID_MODES.inspect}, got #{value.inspect}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def coerce_roots(raw)
|
|
193
|
+
roots = Array(raw.fetch("roots", DEFAULT_ROOTS)).map(&:to_s).freeze
|
|
194
|
+
return roots unless roots.empty?
|
|
195
|
+
|
|
196
|
+
raise ArgumentError,
|
|
197
|
+
"dependencies.source_inference[].roots must not be empty when supplied " \
|
|
198
|
+
"(omit the key to fall back to the default #{DEFAULT_ROOTS.inspect})"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def coerce_budget_overrun_strategy(value)
|
|
202
|
+
symbol = value.to_sym
|
|
203
|
+
return symbol if VALID_BUDGET_OVERRUN_STRATEGIES.include?(symbol)
|
|
204
|
+
|
|
205
|
+
raise ArgumentError,
|
|
206
|
+
"dependencies.budget_overrun_strategy must be one of " \
|
|
207
|
+
"#{VALID_BUDGET_OVERRUN_STRATEGIES.inspect}, got #{value.inspect}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# ADR-10 slice 4. Per-gem catalog cap is mandatory
|
|
211
|
+
# (the parser supplies the default before this is
|
|
212
|
+
# called, so `nil` only reaches here on an explicit
|
|
213
|
+
# `budget_per_gem: ~`). Range bounds match
|
|
214
|
+
# MIN_BUDGET_PER_GEM .. MAX_BUDGET_PER_GEM
|
|
215
|
+
# (i.e. 0.25× – 4× of the default).
|
|
216
|
+
def coerce_budget_per_gem(value)
|
|
217
|
+
unless value.is_a?(Integer)
|
|
218
|
+
raise ArgumentError,
|
|
219
|
+
"dependencies.budget_per_gem must be an Integer, " \
|
|
220
|
+
"got #{value.inspect}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
unless value.between?(MIN_BUDGET_PER_GEM, MAX_BUDGET_PER_GEM)
|
|
224
|
+
raise ArgumentError,
|
|
225
|
+
"dependencies.budget_per_gem must be in the range " \
|
|
226
|
+
"#{MIN_BUDGET_PER_GEM}..#{MAX_BUDGET_PER_GEM}, " \
|
|
227
|
+
"got #{value.inspect}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
value
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -43,9 +43,14 @@ module Rigor
|
|
|
43
43
|
"call.argument-type-mismatch" => :warning,
|
|
44
44
|
"call.possible-nil-receiver" => :warning,
|
|
45
45
|
"flow.always-raises" => :warning,
|
|
46
|
+
"flow.unreachable-branch" => :info,
|
|
47
|
+
"flow.dead-assignment" => :info,
|
|
48
|
+
"flow.always-truthy-condition" => :info,
|
|
46
49
|
"assert.type-mismatch" => :error,
|
|
47
50
|
"dump.type" => :info,
|
|
48
|
-
"def.return-type-mismatch" => :warning
|
|
51
|
+
"def.return-type-mismatch" => :warning,
|
|
52
|
+
"def.method-visibility-mismatch" => :warning,
|
|
53
|
+
"def.ivar-write-mismatch" => :warning
|
|
49
54
|
}.freeze,
|
|
50
55
|
balanced: {
|
|
51
56
|
"call.undefined-method" => :error,
|
|
@@ -53,9 +58,14 @@ module Rigor
|
|
|
53
58
|
"call.argument-type-mismatch" => :error,
|
|
54
59
|
"call.possible-nil-receiver" => :error,
|
|
55
60
|
"flow.always-raises" => :error,
|
|
61
|
+
"flow.unreachable-branch" => :warning,
|
|
62
|
+
"flow.dead-assignment" => :warning,
|
|
63
|
+
"flow.always-truthy-condition" => :warning,
|
|
56
64
|
"assert.type-mismatch" => :error,
|
|
57
65
|
"dump.type" => :info,
|
|
58
|
-
"def.return-type-mismatch" => :warning
|
|
66
|
+
"def.return-type-mismatch" => :warning,
|
|
67
|
+
"def.method-visibility-mismatch" => :error,
|
|
68
|
+
"def.ivar-write-mismatch" => :warning
|
|
59
69
|
}.freeze,
|
|
60
70
|
strict: {
|
|
61
71
|
"call.undefined-method" => :error,
|
|
@@ -63,9 +73,14 @@ module Rigor
|
|
|
63
73
|
"call.argument-type-mismatch" => :error,
|
|
64
74
|
"call.possible-nil-receiver" => :error,
|
|
65
75
|
"flow.always-raises" => :error,
|
|
76
|
+
"flow.unreachable-branch" => :error,
|
|
77
|
+
"flow.dead-assignment" => :error,
|
|
78
|
+
"flow.always-truthy-condition" => :error,
|
|
66
79
|
"assert.type-mismatch" => :error,
|
|
67
80
|
"dump.type" => :error,
|
|
68
|
-
"def.return-type-mismatch" => :error
|
|
81
|
+
"def.return-type-mismatch" => :error,
|
|
82
|
+
"def.method-visibility-mismatch" => :error,
|
|
83
|
+
"def.ivar-write-mismatch" => :error
|
|
69
84
|
}.freeze
|
|
70
85
|
}.freeze
|
|
71
86
|
|