rigortype 0.1.2 → 0.1.4
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 +135 -31
- data/lib/rigor/analysis/check_rules.rb +10 -18
- data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +75 -0
- data/lib/rigor/analysis/dependency_source_inference/builder.rb +113 -0
- data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +72 -0
- data/lib/rigor/analysis/dependency_source_inference/index.rb +139 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +200 -0
- data/lib/rigor/analysis/dependency_source_inference.rb +38 -0
- data/lib/rigor/analysis/diagnostic.rb +0 -2
- data/lib/rigor/analysis/fact_store.rb +11 -3
- data/lib/rigor/analysis/rule_catalog.rb +2 -2
- data/lib/rigor/analysis/runner.rb +206 -6
- data/lib/rigor/builtins/imported_refinements.rb +360 -55
- data/lib/rigor/cache/descriptor.rb +59 -6
- data/lib/rigor/cache/store.rb +1 -1
- data/lib/rigor/cli/diff_command.rb +1 -1
- data/lib/rigor/cli/sig_gen_command.rb +173 -0
- data/lib/rigor/cli/type_of_command.rb +1 -1
- data/lib/rigor/cli/type_scan_renderer.rb +1 -1
- data/lib/rigor/cli/type_scan_report.rb +2 -2
- data/lib/rigor/cli.rb +9 -1
- data/lib/rigor/configuration/dependencies.rb +235 -0
- data/lib/rigor/configuration.rb +45 -11
- data/lib/rigor/environment.rb +47 -4
- data/lib/rigor/flow_contribution/conflict.rb +2 -2
- data/lib/rigor/flow_contribution/element.rb +1 -1
- data/lib/rigor/flow_contribution/fact.rb +1 -1
- data/lib/rigor/flow_contribution/merge_result.rb +1 -1
- data/lib/rigor/flow_contribution/merger.rb +7 -3
- data/lib/rigor/flow_contribution.rb +2 -2
- data/lib/rigor/inference/block_parameter_binder.rb +0 -2
- data/lib/rigor/inference/coverage_scanner.rb +1 -1
- data/lib/rigor/inference/expression_typer.rb +67 -11
- data/lib/rigor/inference/fallback.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +3 -5
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +0 -12
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -3
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +118 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -11
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +27 -11
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +0 -4
- data/lib/rigor/inference/method_dispatcher.rb +233 -2
- data/lib/rigor/inference/method_parameter_binder.rb +1 -3
- data/lib/rigor/inference/narrowing.rb +2 -4
- data/lib/rigor/inference/rbs_type_translator.rb +0 -2
- data/lib/rigor/inference/scope_indexer.rb +14 -9
- data/lib/rigor/inference/statement_evaluator.rb +70 -6
- data/lib/rigor/plugin/io_boundary.rb +0 -2
- data/lib/rigor/plugin/loader.rb +2 -2
- data/lib/rigor/plugin/manifest.rb +49 -7
- data/lib/rigor/plugin/registry.rb +11 -0
- data/lib/rigor/plugin/services.rb +1 -1
- data/lib/rigor/plugin/type_node_resolver.rb +52 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/reporter.rb +91 -0
- data/lib/rigor/rbs_extended.rb +131 -32
- data/lib/rigor/scope.rb +25 -8
- data/lib/rigor/sig_gen/classification.rb +36 -0
- data/lib/rigor/sig_gen/generator.rb +1048 -0
- data/lib/rigor/sig_gen/layout_index.rb +108 -0
- data/lib/rigor/sig_gen/method_candidate.rb +62 -0
- data/lib/rigor/sig_gen/observation_collector.rb +391 -0
- data/lib/rigor/sig_gen/observed_call.rb +62 -0
- data/lib/rigor/sig_gen/path_mapper.rb +116 -0
- data/lib/rigor/sig_gen/renderer.rb +157 -0
- data/lib/rigor/sig_gen/type_elaborator.rb +92 -0
- data/lib/rigor/sig_gen/write_result.rb +48 -0
- data/lib/rigor/sig_gen/writer.rb +530 -0
- data/lib/rigor/sig_gen.rb +25 -0
- data/lib/rigor/type/bound_method.rb +79 -0
- data/lib/rigor/type/combinator.rb +195 -2
- data/lib/rigor/type/constant.rb +13 -0
- data/lib/rigor/type/hash_shape.rb +0 -2
- data/lib/rigor/type/union.rb +20 -1
- data/lib/rigor/type.rb +1 -0
- data/lib/rigor/type_node/generic.rb +62 -0
- data/lib/rigor/type_node/identifier.rb +30 -0
- data/lib/rigor/type_node/indexed_access.rb +41 -0
- data/lib/rigor/type_node/integer_literal.rb +29 -0
- data/lib/rigor/type_node/name_scope.rb +52 -0
- data/lib/rigor/type_node/resolver_chain.rb +56 -0
- data/lib/rigor/type_node/string_literal.rb +29 -0
- data/lib/rigor/type_node/symbol_literal.rb +28 -0
- data/lib/rigor/type_node/union.rb +42 -0
- data/lib/rigor/type_node.rb +29 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +2 -0
- data/sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs +10 -0
- data/sig/rigor/analysis/check_rules/dead_assignment_collector.rbs +10 -0
- data/sig/rigor/analysis/dependency_source_inference/gem_resolver.rbs +25 -0
- data/sig/rigor/analysis/dependency_source_inference/index.rbs +9 -0
- data/sig/rigor/cli/diff_command.rbs +4 -0
- data/sig/rigor/cli/explain_command.rbs +4 -0
- data/sig/rigor/cli/sig_gen_command.rbs +4 -0
- data/sig/rigor/cli/type_scan_command.rbs +3 -0
- data/sig/rigor/environment.rbs +6 -2
- data/sig/rigor/inference/builtins/method_catalog.rbs +4 -0
- data/sig/rigor/inference/builtins/numeric_catalog.rbs +3 -0
- data/sig/rigor/inference/builtins.rbs +2 -0
- data/sig/rigor/plugin/access_denied_error.rbs +3 -0
- data/sig/rigor/plugin/base.rbs +6 -0
- data/sig/rigor/plugin/fact_store.rbs +11 -0
- data/sig/rigor/plugin/io_boundary.rbs +4 -0
- data/sig/rigor/plugin/load_error.rbs +6 -0
- data/sig/rigor/plugin/loader.rbs +20 -0
- data/sig/rigor/plugin/manifest.rbs +9 -0
- data/sig/rigor/plugin/registry.rbs +3 -0
- data/sig/rigor/plugin/services.rbs +3 -0
- data/sig/rigor/plugin/trust_policy.rbs +4 -0
- data/sig/rigor/plugin/type_node_resolver.rbs +3 -0
- data/sig/rigor/plugin.rbs +8 -0
- data/sig/rigor/scope.rbs +4 -2
- data/sig/rigor/type.rbs +28 -6
- metadata +58 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optionparser"
|
|
4
|
+
|
|
5
|
+
require_relative "../configuration"
|
|
6
|
+
require_relative "../sig_gen"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
class CLI
|
|
10
|
+
# Executes the `rigor sig-gen` command — ADR-14 slices 1–3.
|
|
11
|
+
#
|
|
12
|
+
# Walks the given paths (or `configuration.paths` when none
|
|
13
|
+
# are supplied), classifies every reachable instance method
|
|
14
|
+
# via {Rigor::SigGen::Generator}, and either prints the
|
|
15
|
+
# resulting RBS skeletons / unified-style diffs (`--print`,
|
|
16
|
+
# `--diff`; slice 1) or writes them to the project signature
|
|
17
|
+
# tree via {Rigor::SigGen::Writer} (`--write`; slice 2).
|
|
18
|
+
#
|
|
19
|
+
# `--write` follows the established Ruby community
|
|
20
|
+
# convention: `lib/foo/bar.rb` → `sig/foo/bar.rbs`. New
|
|
21
|
+
# methods are inserted into the matching class declaration
|
|
22
|
+
# just before its closing `end`; new classes are appended
|
|
23
|
+
# to the file; non-existent target files are created. User-
|
|
24
|
+
# authored declarations are NEVER replaced unless
|
|
25
|
+
# `--overwrite` is set AND the candidate is a
|
|
26
|
+
# `tighter-return`.
|
|
27
|
+
#
|
|
28
|
+
# Parameter policy defaults to `untyped`. `--params=observed`
|
|
29
|
+
# (slice 3) opts in to caller-side observation harvesting:
|
|
30
|
+
# the `ObservationCollector` walks `--observe=PATH...`
|
|
31
|
+
# (default `spec/` when no flag is given AND a `spec/`
|
|
32
|
+
# directory exists), unions per-position arg types, and the
|
|
33
|
+
# generator emits the union per ADR-5 clause 2.
|
|
34
|
+
# `--params=observed-strict` stays reserved-but-inert until
|
|
35
|
+
# the capability-role catalog ships (rejected with a usage
|
|
36
|
+
# error so the surface stays stable).
|
|
37
|
+
class SigGenCommand
|
|
38
|
+
USAGE = "Usage: rigor sig-gen [options] [paths]"
|
|
39
|
+
|
|
40
|
+
VALID_MODES = %w[print diff write].freeze
|
|
41
|
+
VALID_PARAM_POLICIES = %w[untyped observed observed-strict].freeze
|
|
42
|
+
VALID_FORMATS = %w[text json].freeze
|
|
43
|
+
|
|
44
|
+
def initialize(argv:, out:, err:)
|
|
45
|
+
@argv = argv
|
|
46
|
+
@out = out
|
|
47
|
+
@err = err
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Integer] CLI exit status.
|
|
51
|
+
def run
|
|
52
|
+
options = parse_options
|
|
53
|
+
return CLI::EXIT_USAGE if options.nil?
|
|
54
|
+
|
|
55
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
56
|
+
paths = @argv.empty? ? configuration.paths : @argv
|
|
57
|
+
|
|
58
|
+
observations = collect_observations(configuration, options)
|
|
59
|
+
candidates = SigGen::Generator.new(configuration: configuration, paths: paths,
|
|
60
|
+
observations: observations,
|
|
61
|
+
include_private: options.fetch(:include_private)).run
|
|
62
|
+
mode = options.fetch(:mode).to_sym
|
|
63
|
+
|
|
64
|
+
if mode == :write
|
|
65
|
+
dispatch_write(candidates, configuration, options)
|
|
66
|
+
else
|
|
67
|
+
dispatch_print_or_diff(candidates, mode, options)
|
|
68
|
+
end
|
|
69
|
+
0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def dispatch_print_or_diff(candidates, mode, options)
|
|
75
|
+
SigGen::Renderer.new(out: @out).render(
|
|
76
|
+
candidates: candidates,
|
|
77
|
+
mode: mode,
|
|
78
|
+
format: options.fetch(:format),
|
|
79
|
+
selection: options.fetch(:selection)
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def dispatch_write(candidates, configuration, options)
|
|
84
|
+
layout_index = SigGen::LayoutIndex.new(signature_paths: configuration.signature_paths)
|
|
85
|
+
path_mapper = SigGen::PathMapper.new(configuration: configuration, layout_index: layout_index)
|
|
86
|
+
writer = SigGen::Writer.new(path_mapper: path_mapper, overwrite: options.fetch(:overwrite))
|
|
87
|
+
|
|
88
|
+
results = writer.write_all(candidates)
|
|
89
|
+
|
|
90
|
+
SigGen::Renderer.new(out: @out).render_write(results: results, format: options.fetch(:format))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Slice 3 — collect call-site argument observations when
|
|
94
|
+
# `--params=observed` is set. When `--observe=PATH` is
|
|
95
|
+
# not specified, default to `spec/` (skipped silently
|
|
96
|
+
# when the directory is absent).
|
|
97
|
+
def collect_observations(configuration, options)
|
|
98
|
+
return {} if options.fetch(:params) != "observed"
|
|
99
|
+
|
|
100
|
+
observe_paths = options.fetch(:observe)
|
|
101
|
+
observe_paths = ["spec"] if observe_paths.empty? && File.directory?("spec")
|
|
102
|
+
SigGen::ObservationCollector.new(configuration: configuration, paths: observe_paths).collect
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def parse_options
|
|
106
|
+
options = {
|
|
107
|
+
mode: "print",
|
|
108
|
+
format: "text",
|
|
109
|
+
params: "untyped",
|
|
110
|
+
selection: [],
|
|
111
|
+
overwrite: false,
|
|
112
|
+
observe: [],
|
|
113
|
+
include_private: false,
|
|
114
|
+
config: nil
|
|
115
|
+
}
|
|
116
|
+
build_option_parser(options).parse!(@argv)
|
|
117
|
+
|
|
118
|
+
message = validation_error(options)
|
|
119
|
+
return options if message.nil?
|
|
120
|
+
|
|
121
|
+
@err.puts("sig-gen: #{message}")
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def build_option_parser(options) # rubocop:disable Metrics/AbcSize
|
|
126
|
+
OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
|
|
127
|
+
opts.banner = USAGE
|
|
128
|
+
opts.on("--print", "Write RBS skeletons to stdout (default)") { options[:mode] = "print" }
|
|
129
|
+
opts.on("--diff", "Write a unified diff against existing RBS") { options[:mode] = "diff" }
|
|
130
|
+
opts.on("--write", "Write generated RBS to sig/<path>.rbs files") { options[:mode] = "write" }
|
|
131
|
+
opts.on("--overwrite", "Allow tighter-return updates to replace user-authored RBS") do
|
|
132
|
+
options[:overwrite] = true
|
|
133
|
+
end
|
|
134
|
+
opts.on("--include-private", "Emit private / protected instance methods (default: public only)") do
|
|
135
|
+
options[:include_private] = true
|
|
136
|
+
end
|
|
137
|
+
opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
|
|
138
|
+
opts.on("--params=POLICY", "Parameter policy: untyped (default), observed, observed-strict") do |value|
|
|
139
|
+
options[:params] = value
|
|
140
|
+
end
|
|
141
|
+
opts.on("--observe=PATH", "Directory / file to scan for call-site observations (repeatable)") do |value|
|
|
142
|
+
options[:observe] << value
|
|
143
|
+
end
|
|
144
|
+
opts.on("--new-files", "Emit only new-file classifications") do
|
|
145
|
+
options[:selection] << SigGen::Classification::NEW_FILE
|
|
146
|
+
end
|
|
147
|
+
opts.on("--new-methods", "Emit only new-method classifications") do
|
|
148
|
+
options[:selection] << SigGen::Classification::NEW_METHOD
|
|
149
|
+
end
|
|
150
|
+
opts.on("--tighter-returns", "Emit only tighter-return classifications") do
|
|
151
|
+
options[:selection] << SigGen::Classification::TIGHTER_RETURN
|
|
152
|
+
end
|
|
153
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validation_error(options)
|
|
158
|
+
mode = options.fetch(:mode)
|
|
159
|
+
format = options.fetch(:format)
|
|
160
|
+
params = options.fetch(:params)
|
|
161
|
+
|
|
162
|
+
return "--print, --diff, and --write are mutually exclusive flags; pick one" unless VALID_MODES.include?(mode)
|
|
163
|
+
return "unsupported --format=#{format}" unless VALID_FORMATS.include?(format)
|
|
164
|
+
return "unsupported --params=#{params}" unless VALID_PARAM_POLICIES.include?(params)
|
|
165
|
+
if params == "observed-strict"
|
|
166
|
+
return "--params=observed-strict is reserved until the capability-role catalog ships"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -10,7 +10,7 @@ module Rigor
|
|
|
10
10
|
# branches share a single source of truth (the `Report` value object) so
|
|
11
11
|
# the two formats stay in lockstep; that pairing is why this class is a
|
|
12
12
|
# bit longer than the default class-length budget.
|
|
13
|
-
class TypeScanRenderer
|
|
13
|
+
class TypeScanRenderer
|
|
14
14
|
def initialize(out:)
|
|
15
15
|
@out = out
|
|
16
16
|
end
|
|
@@ -5,14 +5,14 @@ module Rigor
|
|
|
5
5
|
# Aggregated report assembled by `TypeScanCommand` and consumed by
|
|
6
6
|
# `TypeScanRenderer`. The struct holds per-file paths, accumulated
|
|
7
7
|
# per-class counts, located fallback events, and any parse errors.
|
|
8
|
-
Report
|
|
8
|
+
class Report < Data.define(
|
|
9
9
|
:files,
|
|
10
10
|
:parse_errors,
|
|
11
11
|
:visits,
|
|
12
12
|
:unrecognized,
|
|
13
13
|
:events,
|
|
14
14
|
:options
|
|
15
|
-
)
|
|
15
|
+
)
|
|
16
16
|
def visited_count
|
|
17
17
|
visits.values.sum
|
|
18
18
|
end
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -24,7 +24,8 @@ module Rigor
|
|
|
24
24
|
"type-of" => :run_type_of,
|
|
25
25
|
"type-scan" => :run_type_scan,
|
|
26
26
|
"explain" => :run_explain,
|
|
27
|
-
"diff" => :run_diff
|
|
27
|
+
"diff" => :run_diff,
|
|
28
|
+
"sig-gen" => :run_sig_gen
|
|
28
29
|
}.freeze
|
|
29
30
|
|
|
30
31
|
def self.start(argv = ARGV, out: $stdout, err: $stderr)
|
|
@@ -279,6 +280,12 @@ module Rigor
|
|
|
279
280
|
DiffCommand.new(argv: @argv, out: @out, err: @err).run
|
|
280
281
|
end
|
|
281
282
|
|
|
283
|
+
def run_sig_gen
|
|
284
|
+
require_relative "cli/sig_gen_command"
|
|
285
|
+
|
|
286
|
+
SigGenCommand.new(argv: @argv, out: @out, err: @err).run
|
|
287
|
+
end
|
|
288
|
+
|
|
282
289
|
def write_result(result, format)
|
|
283
290
|
case format
|
|
284
291
|
when "json"
|
|
@@ -318,6 +325,7 @@ module Rigor
|
|
|
318
325
|
type-scan Report Scope#type_of coverage across PATHs
|
|
319
326
|
explain Print the description of one or all CheckRules
|
|
320
327
|
diff Compare current diagnostics to a saved baseline JSON
|
|
328
|
+
sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
|
|
321
329
|
version Print the Rigor version
|
|
322
330
|
help Print this help
|
|
323
331
|
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
|
|
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
|
+
class Entry < Data.define(:gem, :mode, :roots)
|
|
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
|
data/lib/rigor/configuration.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
|
|
5
|
+
require_relative "configuration/dependencies"
|
|
5
6
|
require_relative "configuration/severity_profile"
|
|
6
7
|
|
|
7
8
|
module Rigor
|
|
@@ -58,7 +59,11 @@ module Rigor
|
|
|
58
59
|
"allowed_url_hosts" => []
|
|
59
60
|
},
|
|
60
61
|
"severity_profile" => "balanced",
|
|
61
|
-
"severity_overrides" => {}
|
|
62
|
+
"severity_overrides" => {},
|
|
63
|
+
"dependencies" => {
|
|
64
|
+
"source_inference" => [],
|
|
65
|
+
"budget_per_gem" => Configuration::Dependencies::DEFAULT_BUDGET_PER_GEM
|
|
66
|
+
}
|
|
62
67
|
}.freeze
|
|
63
68
|
|
|
64
69
|
# Top-level keys whose values are file/directory paths that
|
|
@@ -72,7 +77,8 @@ module Rigor
|
|
|
72
77
|
:libraries, :signature_paths, :fold_platform_specific_paths,
|
|
73
78
|
:plugins_io_network, :plugins_io_allowed_paths,
|
|
74
79
|
:plugins_io_allowed_url_hosts,
|
|
75
|
-
:severity_profile, :severity_overrides
|
|
80
|
+
:severity_profile, :severity_overrides,
|
|
81
|
+
:dependencies
|
|
76
82
|
|
|
77
83
|
# Loads a configuration file.
|
|
78
84
|
#
|
|
@@ -174,17 +180,41 @@ module Rigor
|
|
|
174
180
|
|
|
175
181
|
merged = left.dup
|
|
176
182
|
right.each do |key, value|
|
|
177
|
-
merged[key] =
|
|
178
|
-
deep_merge(merged[key], value)
|
|
179
|
-
else
|
|
180
|
-
value
|
|
181
|
-
end
|
|
183
|
+
merged[key] = merge_value(key, merged, value)
|
|
182
184
|
end
|
|
183
185
|
merged
|
|
184
186
|
end
|
|
185
|
-
private_class_method :load_with_includes, :merge_includes, :resolve_paths_in, :deep_merge
|
|
186
187
|
|
|
187
|
-
#
|
|
188
|
+
# Most keys are right-wins (override) or recursively
|
|
189
|
+
# merged hashes. ADR-10 § "config-conflict diagnostic"
|
|
190
|
+
# carves out `dependencies.source_inference[]`: the
|
|
191
|
+
# per-gem merge across `includes:` chains needs union
|
|
192
|
+
# behaviour with mode-conflict detection. The Hash itself
|
|
193
|
+
# still merges deeply; only the inner array gets
|
|
194
|
+
# concatenated so {Dependencies.from_h} sees every
|
|
195
|
+
# contributor's entries and can dedupe them.
|
|
196
|
+
def self.merge_value(key, merged, value)
|
|
197
|
+
if key == "dependencies" && merged[key].is_a?(Hash) && value.is_a?(Hash)
|
|
198
|
+
merge_dependencies_hash(merged[key], value)
|
|
199
|
+
elsif merged.key?(key) && merged[key].is_a?(Hash) && value.is_a?(Hash)
|
|
200
|
+
deep_merge(merged[key], value)
|
|
201
|
+
else
|
|
202
|
+
value
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def self.merge_dependencies_hash(left, right)
|
|
207
|
+
out = deep_merge(left, right)
|
|
208
|
+
left_si = Array(left["source_inference"])
|
|
209
|
+
right_si = Array(right["source_inference"])
|
|
210
|
+
both_empty = left_si.empty? && right_si.empty?
|
|
211
|
+
out["source_inference"] = left_si + right_si unless both_empty # rigor:disable flow.always-truthy-condition
|
|
212
|
+
out
|
|
213
|
+
end
|
|
214
|
+
private_class_method :load_with_includes, :merge_includes, :resolve_paths_in, :deep_merge,
|
|
215
|
+
:merge_value, :merge_dependencies_hash
|
|
216
|
+
|
|
217
|
+
# rubocop:disable Metrics/AbcSize
|
|
188
218
|
def initialize(data = DEFAULTS)
|
|
189
219
|
cache = DEFAULTS.fetch("cache").merge(data.fetch("cache", {}))
|
|
190
220
|
plugins_io = DEFAULTS.fetch("plugins_io").merge(data.fetch("plugins_io", {}))
|
|
@@ -213,8 +243,11 @@ module Rigor
|
|
|
213
243
|
@severity_overrides = coerce_severity_overrides(
|
|
214
244
|
data.fetch("severity_overrides", DEFAULTS.fetch("severity_overrides"))
|
|
215
245
|
)
|
|
246
|
+
@dependencies = Dependencies.from_h(
|
|
247
|
+
data.fetch("dependencies", DEFAULTS.fetch("dependencies"))
|
|
248
|
+
)
|
|
216
249
|
end
|
|
217
|
-
# rubocop:enable Metrics/AbcSize
|
|
250
|
+
# rubocop:enable Metrics/AbcSize
|
|
218
251
|
|
|
219
252
|
def to_h
|
|
220
253
|
{
|
|
@@ -235,7 +268,8 @@ module Rigor
|
|
|
235
268
|
"allowed_url_hosts" => plugins_io_allowed_url_hosts
|
|
236
269
|
},
|
|
237
270
|
"severity_profile" => severity_profile.to_s,
|
|
238
|
-
"severity_overrides" => severity_overrides.to_h { |k, v| [k, v.to_s] }
|
|
271
|
+
"severity_overrides" => severity_overrides.to_h { |k, v| [k, v.to_s] },
|
|
272
|
+
"dependencies" => dependencies.to_h
|
|
239
273
|
}
|
|
240
274
|
end
|
|
241
275
|
|
data/lib/rigor/environment.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "environment/class_registry"
|
|
4
4
|
require_relative "environment/rbs_loader"
|
|
5
|
+
require_relative "type_node/name_scope"
|
|
6
|
+
require_relative "type_node/resolver_chain"
|
|
5
7
|
|
|
6
8
|
module Rigor
|
|
7
9
|
# The engine's view of the type universe outside the current scope.
|
|
@@ -41,7 +43,8 @@ module Rigor
|
|
|
41
43
|
prism rbs
|
|
42
44
|
].freeze
|
|
43
45
|
|
|
44
|
-
attr_reader :class_registry, :rbs_loader, :plugin_registry
|
|
46
|
+
attr_reader :class_registry, :rbs_loader, :plugin_registry, :dependency_source_index,
|
|
47
|
+
:rbs_extended_reporter, :boundary_cross_reporter, :name_scope
|
|
45
48
|
|
|
46
49
|
# @param class_registry [Rigor::Environment::ClassRegistry]
|
|
47
50
|
# @param rbs_loader [Rigor::Environment::RbsLoader, nil] when nil the
|
|
@@ -57,10 +60,21 @@ module Rigor
|
|
|
57
60
|
# default), no plugin-level return-type contribution
|
|
58
61
|
# participates — useful for tests, the `Environment.default`
|
|
59
62
|
# facade, and analyses that don't load plugins.
|
|
60
|
-
|
|
63
|
+
# @param dependency_source_index [Rigor::Analysis::DependencySourceInference::Index, nil]
|
|
64
|
+
# ADR-10 slice 2b-ii. The per-run index of opt-in gem
|
|
65
|
+
# sources the dispatcher consults BELOW RBS dispatch.
|
|
66
|
+
# When nil (the default), no dep-source contribution
|
|
67
|
+
# participates and the dispatcher tier is a no-op.
|
|
68
|
+
def initialize(class_registry: ClassRegistry.default, rbs_loader: nil,
|
|
69
|
+
plugin_registry: nil, dependency_source_index: nil,
|
|
70
|
+
rbs_extended_reporter: nil, boundary_cross_reporter: nil)
|
|
61
71
|
@class_registry = class_registry
|
|
62
72
|
@rbs_loader = rbs_loader
|
|
63
73
|
@plugin_registry = plugin_registry
|
|
74
|
+
@dependency_source_index = dependency_source_index
|
|
75
|
+
@rbs_extended_reporter = rbs_extended_reporter
|
|
76
|
+
@boundary_cross_reporter = boundary_cross_reporter
|
|
77
|
+
@name_scope = build_name_scope
|
|
64
78
|
freeze
|
|
65
79
|
end
|
|
66
80
|
|
|
@@ -90,7 +104,9 @@ module Rigor
|
|
|
90
104
|
# reflection artefacts) consult the cache. Pass `nil` (the
|
|
91
105
|
# default) to skip caching for this environment.
|
|
92
106
|
# @return [Rigor::Environment]
|
|
93
|
-
def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil,
|
|
107
|
+
def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil, # rubocop:disable Metrics/ParameterLists
|
|
108
|
+
plugin_registry: nil, dependency_source_index: nil,
|
|
109
|
+
rbs_extended_reporter: nil, boundary_cross_reporter: nil)
|
|
94
110
|
resolved_paths = signature_paths || default_signature_paths(root)
|
|
95
111
|
merged_libraries = (DEFAULT_LIBRARIES + libraries.map(&:to_s)).uniq
|
|
96
112
|
loader = RbsLoader.new(
|
|
@@ -98,7 +114,13 @@ module Rigor
|
|
|
98
114
|
signature_paths: resolved_paths,
|
|
99
115
|
cache_store: cache_store
|
|
100
116
|
)
|
|
101
|
-
new(
|
|
117
|
+
new(
|
|
118
|
+
rbs_loader: loader,
|
|
119
|
+
plugin_registry: plugin_registry,
|
|
120
|
+
dependency_source_index: dependency_source_index,
|
|
121
|
+
rbs_extended_reporter: rbs_extended_reporter,
|
|
122
|
+
boundary_cross_reporter: boundary_cross_reporter
|
|
123
|
+
)
|
|
102
124
|
end
|
|
103
125
|
|
|
104
126
|
private
|
|
@@ -192,5 +214,26 @@ module Rigor
|
|
|
192
214
|
def normalize_class_name(name)
|
|
193
215
|
name.to_s.delete_prefix("::")
|
|
194
216
|
end
|
|
217
|
+
|
|
218
|
+
# ADR-13 slice 3b — composes the per-run plugin-supplied
|
|
219
|
+
# {Rigor::TypeNode::ResolverChain} into a single
|
|
220
|
+
# {Rigor::TypeNode::NameScope} that the RBS::Extended
|
|
221
|
+
# directive parser threads down to the
|
|
222
|
+
# {Rigor::Builtins::ImportedRefinements::Resolver}. Returns
|
|
223
|
+
# `nil` when no plugin contributes a type-node resolver so
|
|
224
|
+
# the parser short-circuits the chain consultation and
|
|
225
|
+
# behaves bit-for-bit like the v0.1.0 → v0.1.3 default.
|
|
226
|
+
def build_name_scope
|
|
227
|
+
return nil if @plugin_registry.nil? || @plugin_registry.empty?
|
|
228
|
+
|
|
229
|
+
resolvers = @plugin_registry.type_node_resolvers
|
|
230
|
+
return nil if resolvers.empty?
|
|
231
|
+
|
|
232
|
+
TypeNode::NameScope.new(
|
|
233
|
+
resolver: TypeNode::ResolverChain.new(resolvers),
|
|
234
|
+
class_context: nil,
|
|
235
|
+
type_alias_table: {}
|
|
236
|
+
)
|
|
237
|
+
end
|
|
195
238
|
end
|
|
196
239
|
end
|
|
@@ -28,8 +28,8 @@ module Rigor
|
|
|
28
28
|
lower_tier_contradiction
|
|
29
29
|
].freeze
|
|
30
30
|
|
|
31
|
-
Conflict
|
|
32
|
-
def initialize(target:, edge:, kind:, reason:, provenances:, message:)
|
|
31
|
+
class Conflict < Data.define(:target, :edge, :kind, :reason, :provenances, :message)
|
|
32
|
+
def initialize(target:, edge:, kind:, reason:, provenances:, message:)
|
|
33
33
|
unless CONFLICT_VALID_REASONS.include?(reason)
|
|
34
34
|
raise ArgumentError,
|
|
35
35
|
"FlowContribution::Conflict reason must be one of " \
|