rigortype 0.1.0 → 0.1.1
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 +7 -2
- data/data/builtins/ruby_core/array.yml +6 -6
- data/data/builtins/ruby_core/hash.yml +1 -1
- data/data/builtins/ruby_core/io.yml +3 -3
- data/data/builtins/ruby_core/numeric.yml +1 -1
- data/data/builtins/ruby_core/pathname.yml +100 -100
- data/data/builtins/ruby_core/proc.yml +1 -1
- data/data/builtins/ruby_core/time.yml +3 -3
- data/lib/rigor/analysis/runner.rb +88 -5
- data/lib/rigor/builtins/regex_refinement.rb +104 -0
- data/lib/rigor/cli/type_of_command.rb +3 -3
- data/lib/rigor/cli/type_scan_command.rb +4 -4
- data/lib/rigor/cli.rb +11 -4
- data/lib/rigor/configuration.rb +177 -10
- data/lib/rigor/environment.rb +12 -4
- data/lib/rigor/inference/expression_typer.rb +3 -1
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +43 -2
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
- data/lib/rigor/inference/method_dispatcher.rb +50 -1
- data/lib/rigor/inference/narrowing.rb +150 -6
- data/lib/rigor/inference/scope_indexer.rb +49 -15
- data/lib/rigor/inference/statement_evaluator.rb +29 -0
- data/lib/rigor/plugin/base.rb +43 -0
- data/lib/rigor/plugin/fact_store.rb +92 -0
- data/lib/rigor/plugin/load_error.rb +14 -2
- data/lib/rigor/plugin/loader.rb +116 -0
- data/lib/rigor/plugin/manifest.rb +75 -6
- data/lib/rigor/plugin/services.rb +14 -2
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/trinary.rb +1 -1
- data/lib/rigor/type/integer_range.rb +6 -2
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor.rbs +8 -2
- metadata +3 -1
|
@@ -684,7 +684,7 @@ classes:
|
|
|
684
684
|
body_kind: composed
|
|
685
685
|
cexpr_target:
|
|
686
686
|
prelude_at: references/ruby/timev.rb:440
|
|
687
|
-
purity:
|
|
687
|
+
purity: dispatch
|
|
688
688
|
arity: -1
|
|
689
689
|
cfunc:
|
|
690
690
|
defined_at: references/ruby/timev.rb:440
|
|
@@ -726,7 +726,7 @@ classes:
|
|
|
726
726
|
body_kind: composed
|
|
727
727
|
cexpr_target:
|
|
728
728
|
prelude_at: references/ruby/timev.rb:270
|
|
729
|
-
purity:
|
|
729
|
+
purity: dispatch
|
|
730
730
|
arity: -1
|
|
731
731
|
cfunc:
|
|
732
732
|
defined_at: references/ruby/timev.rb:270
|
|
@@ -739,7 +739,7 @@ classes:
|
|
|
739
739
|
body_kind: composed
|
|
740
740
|
cexpr_target:
|
|
741
741
|
prelude_at: references/ruby/timev.rb:329
|
|
742
|
-
purity:
|
|
742
|
+
purity: dispatch
|
|
743
743
|
arity: -2
|
|
744
744
|
cfunc:
|
|
745
745
|
defined_at: references/ruby/timev.rb:329
|
|
@@ -54,22 +54,45 @@ module Rigor
|
|
|
54
54
|
Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths =
|
|
55
55
|
@configuration.fold_platform_specific_paths
|
|
56
56
|
|
|
57
|
+
target_ruby_error = validate_target_ruby
|
|
58
|
+
return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
|
|
59
|
+
|
|
60
|
+
@plugin_registry = load_plugins
|
|
57
61
|
environment = Environment.for_project(
|
|
58
62
|
libraries: @configuration.libraries,
|
|
59
63
|
signature_paths: @configuration.signature_paths,
|
|
60
|
-
cache_store: @cache_store
|
|
64
|
+
cache_store: @cache_store,
|
|
65
|
+
plugin_registry: @plugin_registry
|
|
61
66
|
)
|
|
62
|
-
|
|
63
|
-
@plugin_registry = load_plugins
|
|
64
67
|
expansion = expand_paths(paths)
|
|
65
68
|
|
|
66
69
|
diagnostics = plugin_load_diagnostics
|
|
70
|
+
diagnostics += plugin_prepare_diagnostics
|
|
67
71
|
diagnostics += expansion.fetch(:errors)
|
|
68
72
|
diagnostics += expansion.fetch(:files).flat_map { |path| analyze_file(path, environment) }
|
|
69
73
|
|
|
70
74
|
Result.new(diagnostics: apply_severity_profile(diagnostics))
|
|
71
75
|
end
|
|
72
76
|
|
|
77
|
+
# `target_ruby` flows through to Prism's `version:` option.
|
|
78
|
+
# Prism enforces the supported range and raises
|
|
79
|
+
# `ArgumentError` for versions it does not recognise. Run a
|
|
80
|
+
# one-time smoke parse here so a misconfigured target_ruby
|
|
81
|
+
# surfaces as a single project-level diagnostic instead of
|
|
82
|
+
# crashing the whole run on the first file.
|
|
83
|
+
def validate_target_ruby
|
|
84
|
+
Prism.parse("nil", version: @configuration.target_ruby)
|
|
85
|
+
nil
|
|
86
|
+
rescue ArgumentError => e
|
|
87
|
+
Diagnostic.new(
|
|
88
|
+
path: ".rigor.yml", line: 1, column: 1,
|
|
89
|
+
message: "target_ruby #{@configuration.target_ruby.inspect} is not accepted by Prism: #{e.message}",
|
|
90
|
+
severity: :error,
|
|
91
|
+
rule: "configuration-error",
|
|
92
|
+
source_family: :builtin
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
73
96
|
private
|
|
74
97
|
|
|
75
98
|
# Loads project-configured plugins through {Rigor::Plugin::Loader}
|
|
@@ -183,6 +206,47 @@ module Rigor
|
|
|
183
206
|
end
|
|
184
207
|
end
|
|
185
208
|
|
|
209
|
+
# ADR-9 slice 3 — invokes every loaded plugin's `#prepare`
|
|
210
|
+
# hook once per run, after the loader's `#init` pass and
|
|
211
|
+
# before per-file iteration. Plugins publish facts here
|
|
212
|
+
# for cross-plugin consumption via the shared
|
|
213
|
+
# `services.fact_store`. Failures isolate as
|
|
214
|
+
# `:plugin_loader runtime-error` diagnostics, mirroring the
|
|
215
|
+
# `#diagnostics_for_file` raise envelope in
|
|
216
|
+
# `plugin_runtime_error_diagnostic`.
|
|
217
|
+
#
|
|
218
|
+
# Slice 3 visits plugins in registration order. Slice 5
|
|
219
|
+
# introduces topological ordering by `manifest(consumes:)`
|
|
220
|
+
# so producers always run before consumers; until then,
|
|
221
|
+
# `Configuration#plugins` order MUST be producer-first if
|
|
222
|
+
# cross-plugin dependencies exist.
|
|
223
|
+
def plugin_prepare_diagnostics
|
|
224
|
+
return [] if @plugin_registry.empty?
|
|
225
|
+
|
|
226
|
+
@plugin_registry.plugins.flat_map { |plugin| invoke_plugin_prepare(plugin) }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def invoke_plugin_prepare(plugin)
|
|
230
|
+
plugin.prepare(plugin.services)
|
|
231
|
+
[]
|
|
232
|
+
rescue StandardError => e
|
|
233
|
+
[plugin_prepare_error_diagnostic(plugin, e)]
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def plugin_prepare_error_diagnostic(plugin, error)
|
|
237
|
+
plugin_id = safe_plugin_id(plugin)
|
|
238
|
+
Diagnostic.new(
|
|
239
|
+
path: ".rigor.yml",
|
|
240
|
+
line: 1,
|
|
241
|
+
column: 1,
|
|
242
|
+
message: "plugin #{plugin_id.inspect} raised during prepare: " \
|
|
243
|
+
"#{error.class}: #{error.message}",
|
|
244
|
+
severity: :error,
|
|
245
|
+
rule: "runtime-error",
|
|
246
|
+
source_family: :plugin_loader
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
|
|
186
250
|
# ADR-7 § "Slice 5-A/5-B" — invokes every loaded plugin's
|
|
187
251
|
# per-file diagnostic emission hook
|
|
188
252
|
# (`Plugin::Base#diagnostics_for_file`) and re-stamps the
|
|
@@ -254,7 +318,7 @@ module Rigor
|
|
|
254
318
|
errors = []
|
|
255
319
|
Array(paths).each do |path|
|
|
256
320
|
if File.directory?(path)
|
|
257
|
-
files.concat(Dir.glob(File.join(path, RUBY_GLOB)))
|
|
321
|
+
files.concat(reject_excluded(Dir.glob(File.join(path, RUBY_GLOB))))
|
|
258
322
|
elsif File.file?(path) && path.end_with?(".rb")
|
|
259
323
|
files << path
|
|
260
324
|
elsif File.exist?(path)
|
|
@@ -266,6 +330,25 @@ module Rigor
|
|
|
266
330
|
{ files: files, errors: errors }
|
|
267
331
|
end
|
|
268
332
|
|
|
333
|
+
# `Configuration#exclude_patterns` is a list of glob patterns
|
|
334
|
+
# checked against each globbed path via `File.fnmatch?` (without
|
|
335
|
+
# `FNM_PATHNAME`, so `**` and `*` both span path separators —
|
|
336
|
+
# the patterns behave like substring globs). Built-in defaults
|
|
337
|
+
# exclude `vendor/bundle`, `.bundle`, `node_modules`, and `tmp`
|
|
338
|
+
# so the analyser never walks into vendored deps or build
|
|
339
|
+
# artefacts. User-supplied entries (`.rigor.yml` `exclude:`)
|
|
340
|
+
# layer on top. Explicit file arguments to the CLI bypass this
|
|
341
|
+
# filter — only the directory-glob expansion is filtered.
|
|
342
|
+
def reject_excluded(file_list)
|
|
343
|
+
return file_list if @configuration.exclude_patterns.empty?
|
|
344
|
+
|
|
345
|
+
file_list.reject { |path| excluded?(path) }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def excluded?(path)
|
|
349
|
+
@configuration.exclude_patterns.any? { |pattern| File.fnmatch?(pattern, path) }
|
|
350
|
+
end
|
|
351
|
+
|
|
269
352
|
def path_error(path, message)
|
|
270
353
|
Diagnostic.new(
|
|
271
354
|
path: path,
|
|
@@ -277,7 +360,7 @@ module Rigor
|
|
|
277
360
|
end
|
|
278
361
|
|
|
279
362
|
def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
|
|
280
|
-
parse_result = Prism.parse_file(path)
|
|
363
|
+
parse_result = Prism.parse_file(path, version: @configuration.target_ruby)
|
|
281
364
|
return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
|
|
282
365
|
|
|
283
366
|
scope = Scope.empty(environment: environment)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../type"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Builtins
|
|
7
|
+
# Maps a curated table of canonical regex sub-patterns onto the
|
|
8
|
+
# imported refinement carriers Rigor already ships
|
|
9
|
+
# (`decimal-int-string`, `hex-int-string`, `octal-int-string`,
|
|
10
|
+
# `lowercase-string`, `uppercase-string`, `numeric-string`).
|
|
11
|
+
# See `docs/type-specification/imported-built-in-types.md` for
|
|
12
|
+
# the registry the refinements come from and `docs/MILESTONES.md`
|
|
13
|
+
# § "v0.1.1 — Planned" Track 1 slice 1 for the binding scope of
|
|
14
|
+
# this recogniser.
|
|
15
|
+
#
|
|
16
|
+
# The intended consumer is `Inference::Narrowing.analyse_match_write`:
|
|
17
|
+
# given `if /(?<year>\d+)/ =~ str; year; end`, the v0.1.0
|
|
18
|
+
# baseline narrows `year` to plain `String`; v0.1.1 introspects
|
|
19
|
+
# the regex source and narrows further to
|
|
20
|
+
# `decimal-int-string` whenever the named-capture body matches
|
|
21
|
+
# one of the rows in {RULES}.
|
|
22
|
+
#
|
|
23
|
+
# Recognised body shapes (each row admits the `+` quantifier
|
|
24
|
+
# and the bounded `{n}` / `{n,m}` forms with `n >= 1`):
|
|
25
|
+
#
|
|
26
|
+
# - `\d` -> decimal-int-string
|
|
27
|
+
# - `\h` -> hex-int-string
|
|
28
|
+
# - `[0-9a-fA-F]` -> hex-int-string
|
|
29
|
+
# - `[0-9a-f]`, `[0-9A-F]` -> hex-int-string
|
|
30
|
+
# - `[0-7]` -> octal-int-string
|
|
31
|
+
# - `[a-z]` -> lowercase-string
|
|
32
|
+
# - `[A-Z]` -> uppercase-string
|
|
33
|
+
# - `[[:digit:]]` -> numeric-string
|
|
34
|
+
#
|
|
35
|
+
# Anything outside the table returns `nil` so the calling
|
|
36
|
+
# narrowing site falls back to its previous behaviour
|
|
37
|
+
# (plain `String`). Arbitrary regex semantic equivalence is
|
|
38
|
+
# undecidable, so the table is intentionally a small audited
|
|
39
|
+
# set of canonical shapes rather than a general equivalence
|
|
40
|
+
# checker.
|
|
41
|
+
module RegexRefinement
|
|
42
|
+
# `+` (one-or-more) or `{n}` / `{n,m}` (n >= 1, m >= n).
|
|
43
|
+
# The bound check is enforced separately by
|
|
44
|
+
# {valid_bounds?} after the structural match succeeds, so
|
|
45
|
+
# forms like `\d{0,5}` or `\d{5,3}` reject even though they
|
|
46
|
+
# parse syntactically.
|
|
47
|
+
QUANTIFIER_SOURCE = '(?:\+|\{\d+(?:,\d+)?\})'
|
|
48
|
+
private_constant :QUANTIFIER_SOURCE
|
|
49
|
+
|
|
50
|
+
RULES = [
|
|
51
|
+
[/\A\\d#{QUANTIFIER_SOURCE}\z/, :decimal_int_string],
|
|
52
|
+
[/\A\\h#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
53
|
+
[/\A\[0-9a-fA-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
54
|
+
[/\A\[0-9a-f\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
55
|
+
[/\A\[0-9A-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
|
|
56
|
+
[/\A\[0-7\]#{QUANTIFIER_SOURCE}\z/, :octal_int_string],
|
|
57
|
+
[/\A\[a-z\]#{QUANTIFIER_SOURCE}\z/, :lowercase_string],
|
|
58
|
+
[/\A\[A-Z\]#{QUANTIFIER_SOURCE}\z/, :uppercase_string],
|
|
59
|
+
[/\A\[\[:digit:\]\]#{QUANTIFIER_SOURCE}\z/, :numeric_string]
|
|
60
|
+
].freeze
|
|
61
|
+
private_constant :RULES
|
|
62
|
+
|
|
63
|
+
BOUND_RE = /\{(\d+)(?:,(\d+))?\}\z/
|
|
64
|
+
private_constant :BOUND_RE
|
|
65
|
+
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
# @param body [String, nil] a regex sub-pattern, typically the
|
|
69
|
+
# inner body of a `(?<name>body)` named capture. Anchors
|
|
70
|
+
# (`\A`, `\z`, `^`, `$`) are not stripped — the recogniser
|
|
71
|
+
# table targets bodies that the regex engine treats as
|
|
72
|
+
# anchored to the capture group bounds.
|
|
73
|
+
# @return [Rigor::Type, nil] the matching imported refinement
|
|
74
|
+
# carrier, or `nil` if `body` is not a recognised shape.
|
|
75
|
+
def for_capture_body(body)
|
|
76
|
+
return nil if body.nil? || body.empty?
|
|
77
|
+
|
|
78
|
+
rule = RULES.find { |pattern, _| pattern.match?(body) }
|
|
79
|
+
return nil if rule.nil?
|
|
80
|
+
return nil unless valid_bounds?(body)
|
|
81
|
+
|
|
82
|
+
Type::Combinator.public_send(rule.last)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Filters the bounded-quantifier forms to ones whose lower
|
|
86
|
+
# bound is at least 1 and whose upper bound (if any) is at
|
|
87
|
+
# least the lower bound. Without this, `\d{0,5}` would be
|
|
88
|
+
# accepted even though it admits the empty string, which is
|
|
89
|
+
# not a valid `decimal-int-string`.
|
|
90
|
+
def valid_bounds?(body)
|
|
91
|
+
m = BOUND_RE.match(body)
|
|
92
|
+
return true if m.nil?
|
|
93
|
+
|
|
94
|
+
low = Integer(m[1])
|
|
95
|
+
return false if low < 1
|
|
96
|
+
|
|
97
|
+
high = m[2] && Integer(m[2])
|
|
98
|
+
return true if high.nil?
|
|
99
|
+
|
|
100
|
+
low <= high
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -48,7 +48,7 @@ module Rigor
|
|
|
48
48
|
private
|
|
49
49
|
|
|
50
50
|
def parse_options
|
|
51
|
-
options = { format: "text", trace: false, config:
|
|
51
|
+
options = { format: "text", trace: false, config: nil }
|
|
52
52
|
|
|
53
53
|
parser = OptionParser.new do |opts|
|
|
54
54
|
opts.banner = USAGE
|
|
@@ -65,8 +65,9 @@ module Rigor
|
|
|
65
65
|
file, line, column = target
|
|
66
66
|
return 1 unless file_exists?(file)
|
|
67
67
|
|
|
68
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
68
69
|
source = File.read(file)
|
|
69
|
-
parse_result = Prism.parse(source, filepath: file)
|
|
70
|
+
parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
|
|
70
71
|
return 1 if parse_errors?(parse_result, file)
|
|
71
72
|
|
|
72
73
|
node = locate_node(source: source, root: parse_result.value, file: file, line: line, column: column)
|
|
@@ -74,7 +75,6 @@ module Rigor
|
|
|
74
75
|
return 1 if node.nil?
|
|
75
76
|
|
|
76
77
|
tracer = options[:trace] ? Inference::FallbackTracer.new : nil
|
|
77
|
-
configuration = Configuration.load(options.fetch(:config))
|
|
78
78
|
base_scope = Scope.empty(environment: project_environment(file, configuration))
|
|
79
79
|
|
|
80
80
|
# Build a per-node scope index so locals bound earlier in the
|
|
@@ -46,7 +46,7 @@ module Rigor
|
|
|
46
46
|
|
|
47
47
|
def parse_options
|
|
48
48
|
options = { format: "text", limit: 10, show_recognized: false, threshold: nil,
|
|
49
|
-
config:
|
|
49
|
+
config: nil }
|
|
50
50
|
|
|
51
51
|
parser = OptionParser.new do |opts|
|
|
52
52
|
opts.banner = USAGE
|
|
@@ -93,7 +93,7 @@ module Rigor
|
|
|
93
93
|
scope = Scope.empty(environment: project_environment(configuration))
|
|
94
94
|
scanner = Inference::CoverageScanner.new(scope: scope)
|
|
95
95
|
accumulator = ScanAccumulator.new
|
|
96
|
-
paths.each { |path| scan_one(path, scanner, accumulator) }
|
|
96
|
+
paths.each { |path| scan_one(path, scanner, accumulator, configuration) }
|
|
97
97
|
accumulator.to_report(paths, options)
|
|
98
98
|
end
|
|
99
99
|
|
|
@@ -107,9 +107,9 @@ module Rigor
|
|
|
107
107
|
)
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
-
def scan_one(path, scanner, accumulator)
|
|
110
|
+
def scan_one(path, scanner, accumulator, configuration)
|
|
111
111
|
source = File.read(path)
|
|
112
|
-
parse_result = Prism.parse(source, filepath: path)
|
|
112
|
+
parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
|
|
113
113
|
if parse_result.errors.any?
|
|
114
114
|
accumulator.record_parse_error(path, parse_result.errors)
|
|
115
115
|
return
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -70,11 +70,11 @@ module Rigor
|
|
|
70
70
|
|
|
71
71
|
options = parse_check_options
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
74
|
+
cache_root = configuration.cache_path
|
|
74
75
|
handle_clear_cache(cache_root) if options.fetch(:clear_cache)
|
|
75
76
|
cache_store = options.fetch(:no_cache) ? nil : Cache::Store.new(root: cache_root)
|
|
76
77
|
|
|
77
|
-
configuration = Configuration.load(options.fetch(:config))
|
|
78
78
|
paths = @argv.empty? ? configuration.paths : @argv
|
|
79
79
|
runner = Analysis::Runner.new(
|
|
80
80
|
configuration: configuration,
|
|
@@ -90,7 +90,9 @@ module Rigor
|
|
|
90
90
|
|
|
91
91
|
def parse_check_options
|
|
92
92
|
options = {
|
|
93
|
-
|
|
93
|
+
# `nil` triggers `Configuration.discover` (`.rigor.yml` then
|
|
94
|
+
# `.rigor.dist.yml`); an explicit `--config=PATH` overrides.
|
|
95
|
+
config: nil,
|
|
94
96
|
format: "text",
|
|
95
97
|
explain: false,
|
|
96
98
|
cache_stats: false,
|
|
@@ -170,9 +172,14 @@ module Rigor
|
|
|
170
172
|
end
|
|
171
173
|
|
|
172
174
|
def run_init
|
|
175
|
+
# Default destination is `.rigor.dist.yml` — the
|
|
176
|
+
# project-default config that gets committed. Developers
|
|
177
|
+
# who want a personal override layer create `.rigor.yml`
|
|
178
|
+
# alongside it (auto-discovery prefers `.rigor.yml` when
|
|
179
|
+
# both are present; no implicit merge).
|
|
173
180
|
options = {
|
|
174
181
|
force: false,
|
|
175
|
-
path:
|
|
182
|
+
path: ".rigor.dist.yml"
|
|
176
183
|
}
|
|
177
184
|
|
|
178
185
|
parser = OptionParser.new do |opts|
|
data/lib/rigor/configuration.rb
CHANGED
|
@@ -6,10 +6,44 @@ require_relative "configuration/severity_profile"
|
|
|
6
6
|
|
|
7
7
|
module Rigor
|
|
8
8
|
class Configuration # rubocop:disable Metrics/ClassLength
|
|
9
|
-
|
|
9
|
+
# File-discovery order for `Configuration.load(nil)`.
|
|
10
|
+
#
|
|
11
|
+
# The first file present is loaded; the others are NOT
|
|
12
|
+
# implicitly merged. To extend a base config explicitly the
|
|
13
|
+
# winning file MUST list the base via `includes:`.
|
|
14
|
+
#
|
|
15
|
+
# `.rigor.yml` is a developer-local override (typically
|
|
16
|
+
# gitignored); `.rigor.dist.yml` is the project default
|
|
17
|
+
# (committed to the repo). When both are present the
|
|
18
|
+
# developer's local override wins outright — there is no
|
|
19
|
+
# implicit auto-merge.
|
|
20
|
+
DISCOVERY_ORDER = %w[.rigor.yml .rigor.dist.yml].freeze
|
|
21
|
+
# Back-compat alias. Keep here so external callers that read
|
|
22
|
+
# `Configuration::DEFAULT_PATH` for help text / fixture paths
|
|
23
|
+
# still work; the discovery list is the canonical source.
|
|
24
|
+
DEFAULT_PATH = DISCOVERY_ORDER.first
|
|
25
|
+
|
|
26
|
+
# Built-in exclusion patterns appended to `exclude:` so vendored
|
|
27
|
+
# dependencies, Bundler artefacts, and JavaScript node_modules are
|
|
28
|
+
# never analysed by accident when a directory glob expands. Users
|
|
29
|
+
# cannot disable these defaults; the trade-off is that analysing
|
|
30
|
+
# any of these paths is essentially never what the user wants
|
|
31
|
+
# (they're build outputs / external dependencies, not source).
|
|
32
|
+
#
|
|
33
|
+
# We deliberately keep this list narrow. `tmp/` and similar
|
|
34
|
+
# directories vary across project layouts (Rails has `tmp/`,
|
|
35
|
+
# libraries usually don't); user-supplied `exclude:` entries
|
|
36
|
+
# in `.rigor.yml` cover the project-specific cases.
|
|
37
|
+
BUILTIN_EXCLUDES = %w[
|
|
38
|
+
**/vendor/bundle/**
|
|
39
|
+
**/.bundle/**
|
|
40
|
+
**/node_modules/**
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
10
43
|
DEFAULTS = {
|
|
11
44
|
"target_ruby" => "4.0",
|
|
12
45
|
"paths" => ["lib"],
|
|
46
|
+
"exclude" => [],
|
|
13
47
|
"plugins" => [],
|
|
14
48
|
"disable" => [],
|
|
15
49
|
"libraries" => [],
|
|
@@ -26,27 +60,136 @@ module Rigor
|
|
|
26
60
|
"severity_overrides" => {}
|
|
27
61
|
}.freeze
|
|
28
62
|
|
|
29
|
-
|
|
63
|
+
# Top-level keys whose values are file/directory paths that
|
|
64
|
+
# MUST be resolved relative to the config file's directory.
|
|
65
|
+
# `exclude:` is intentionally NOT in this list — its entries
|
|
66
|
+
# are glob patterns (`**/vendor/**`), not paths.
|
|
67
|
+
PATH_KEYS = %w[paths signature_paths].freeze
|
|
68
|
+
private_constant :PATH_KEYS
|
|
69
|
+
|
|
70
|
+
attr_reader :target_ruby, :paths, :exclude_patterns, :plugins, :cache_path, :disabled_rules,
|
|
30
71
|
:libraries, :signature_paths, :fold_platform_specific_paths,
|
|
31
72
|
:plugins_io_network, :plugins_io_allowed_paths,
|
|
32
73
|
:severity_profile, :severity_overrides
|
|
33
74
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
75
|
+
# Loads a configuration file.
|
|
76
|
+
#
|
|
77
|
+
# `path == nil` triggers auto-discovery against
|
|
78
|
+
# {DISCOVERY_ORDER}. The first present file in that list is
|
|
79
|
+
# loaded; if none exist the built-in {DEFAULTS} are used.
|
|
80
|
+
#
|
|
81
|
+
# When a path is supplied (whether by auto-discovery or by
|
|
82
|
+
# the caller) the YAML body is processed for `includes:`
|
|
83
|
+
# recursively, and every relative path inside path-bearing
|
|
84
|
+
# keys (`paths:`, `signature_paths:`, `plugins_io.allowed_paths:`,
|
|
85
|
+
# `includes:`) is resolved against THAT file's directory.
|
|
86
|
+
# The resolution is per-file: an included file's relative
|
|
87
|
+
# paths resolve against the included file's directory, not
|
|
88
|
+
# the top-level file. Path resolution mirrors
|
|
89
|
+
# [PHPStan](https://phpstan.org/config-reference#paths).
|
|
90
|
+
def self.load(path = nil)
|
|
91
|
+
resolved = path || discover
|
|
92
|
+
return new(DEFAULTS) if resolved.nil? || !File.exist?(resolved)
|
|
40
93
|
|
|
94
|
+
data = load_with_includes(resolved)
|
|
41
95
|
new(DEFAULTS.merge(data))
|
|
42
96
|
end
|
|
43
97
|
|
|
44
|
-
|
|
98
|
+
# Returns the path to the config file Rigor would load
|
|
99
|
+
# under auto-discovery, or `nil` when neither candidate
|
|
100
|
+
# exists. Public so the CLI / spec drift checks can
|
|
101
|
+
# introspect the resolved file.
|
|
102
|
+
def self.discover
|
|
103
|
+
DISCOVERY_ORDER.find { |candidate| File.exist?(candidate) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Reads `path` (which MUST exist) plus every file listed in
|
|
107
|
+
# its `includes:` chain, merging them under the order:
|
|
108
|
+
# included files first (in declaration order), then the
|
|
109
|
+
# current file's own keys override. Relative paths inside
|
|
110
|
+
# each file are resolved against that file's directory.
|
|
111
|
+
def self.load_with_includes(path, visited: Set.new)
|
|
112
|
+
absolute = File.expand_path(path)
|
|
113
|
+
raise ArgumentError, "circular include: #{absolute}" if visited.include?(absolute)
|
|
114
|
+
|
|
115
|
+
raw = YAML.safe_load_file(absolute, aliases: false) || {}
|
|
116
|
+
raise ArgumentError, "config file must be a YAML mapping: #{absolute}" unless raw.is_a?(Hash)
|
|
117
|
+
|
|
118
|
+
base_dir = File.dirname(absolute)
|
|
119
|
+
includes = Array(raw.delete("includes") || [])
|
|
120
|
+
data = resolve_paths_in(raw, base_dir)
|
|
121
|
+
next_visited = visited + [absolute]
|
|
122
|
+
merge_includes(data, includes, base_dir, next_visited)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.merge_includes(data, includes, base_dir, visited)
|
|
126
|
+
return data if includes.empty?
|
|
127
|
+
|
|
128
|
+
accumulated = {}
|
|
129
|
+
includes.each do |inc|
|
|
130
|
+
inc_path = File.expand_path(inc.to_s, base_dir)
|
|
131
|
+
unless File.exist?(inc_path)
|
|
132
|
+
raise ArgumentError, "include not found: #{inc.inspect} (referenced from #{base_dir})"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
accumulated = deep_merge(accumulated, load_with_includes(inc_path, visited: visited))
|
|
136
|
+
end
|
|
137
|
+
deep_merge(accumulated, data)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Per-file path resolution. Each path-bearing key listed in
|
|
141
|
+
# {PATH_KEYS} plus the nested `plugins_io.allowed_paths:`
|
|
142
|
+
# entries get their relative paths expanded against the
|
|
143
|
+
# config file's directory. `cache.path:` is intentionally
|
|
144
|
+
# left as-is so end-user messages (e.g. `--cache-stats`
|
|
145
|
+
# output) keep the project-relative form the user wrote.
|
|
146
|
+
def self.resolve_paths_in(data, base_dir)
|
|
147
|
+
return data unless data.is_a?(Hash)
|
|
148
|
+
|
|
149
|
+
out = data.dup
|
|
150
|
+
PATH_KEYS.each { |key| resolve_path_key!(out, key, base_dir) }
|
|
151
|
+
resolve_plugins_io_paths!(out, base_dir)
|
|
152
|
+
out
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def self.resolve_path_key!(out, key, base_dir)
|
|
156
|
+
return unless out.key?(key) && !out[key].nil?
|
|
157
|
+
|
|
158
|
+
out[key] = Array(out[key]).map { |p| File.expand_path(p.to_s, base_dir) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.resolve_plugins_io_paths!(out, base_dir)
|
|
162
|
+
plugins_io = out["plugins_io"]
|
|
163
|
+
return unless plugins_io.is_a?(Hash) && plugins_io["allowed_paths"]
|
|
164
|
+
|
|
165
|
+
duped = plugins_io.dup
|
|
166
|
+
duped["allowed_paths"] = Array(plugins_io["allowed_paths"]).map { |p| File.expand_path(p.to_s, base_dir) }
|
|
167
|
+
out["plugins_io"] = duped
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.deep_merge(left, right)
|
|
171
|
+
return right unless left.is_a?(Hash) && right.is_a?(Hash)
|
|
172
|
+
|
|
173
|
+
merged = left.dup
|
|
174
|
+
right.each do |key, value|
|
|
175
|
+
merged[key] = if merged.key?(key) && merged[key].is_a?(Hash) && value.is_a?(Hash)
|
|
176
|
+
deep_merge(merged[key], value)
|
|
177
|
+
else
|
|
178
|
+
value
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
merged
|
|
182
|
+
end
|
|
183
|
+
private_class_method :load_with_includes, :merge_includes, :resolve_paths_in, :deep_merge
|
|
184
|
+
|
|
185
|
+
def initialize(data = DEFAULTS) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
45
186
|
cache = DEFAULTS.fetch("cache").merge(data.fetch("cache", {}))
|
|
46
187
|
plugins_io = DEFAULTS.fetch("plugins_io").merge(data.fetch("plugins_io", {}))
|
|
47
188
|
|
|
48
|
-
@target_ruby = data.fetch("target_ruby", DEFAULTS.fetch("target_ruby"))
|
|
189
|
+
@target_ruby = coerce_target_ruby(data.fetch("target_ruby", DEFAULTS.fetch("target_ruby")))
|
|
49
190
|
@paths = Array(data.fetch("paths", DEFAULTS.fetch("paths"))).map(&:to_s)
|
|
191
|
+
user_excludes = Array(data.fetch("exclude", DEFAULTS.fetch("exclude"))).map(&:to_s)
|
|
192
|
+
@exclude_patterns = (BUILTIN_EXCLUDES + user_excludes).uniq.freeze
|
|
50
193
|
@plugins = Array(data.fetch("plugins", DEFAULTS.fetch("plugins"))).map do |entry|
|
|
51
194
|
coerce_plugin_entry(entry)
|
|
52
195
|
end.freeze
|
|
@@ -72,6 +215,7 @@ module Rigor
|
|
|
72
215
|
{
|
|
73
216
|
"target_ruby" => target_ruby,
|
|
74
217
|
"paths" => paths,
|
|
218
|
+
"exclude" => exclude_patterns - BUILTIN_EXCLUDES,
|
|
75
219
|
"plugins" => plugins,
|
|
76
220
|
"disable" => disabled_rules,
|
|
77
221
|
"libraries" => libraries,
|
|
@@ -107,6 +251,29 @@ module Rigor
|
|
|
107
251
|
end
|
|
108
252
|
end
|
|
109
253
|
|
|
254
|
+
# `target_ruby` is passed to `Prism.parse_file(path, version:)` at
|
|
255
|
+
# the analyser's three parse sites (`Analysis::Runner`,
|
|
256
|
+
# `CLI::TypeOfCommand`, `CLI::TypeScanCommand`) so projects that
|
|
257
|
+
# target an older Ruby get parse errors for syntax their target
|
|
258
|
+
# doesn't support. Format validation here is loose — accepts
|
|
259
|
+
# any `<major>.<minor>` or `<major>.<minor>.<patch>` form, plus
|
|
260
|
+
# the literal `"latest"`. Prism itself enforces the supported
|
|
261
|
+
# set and raises `ArgumentError` for versions it does not
|
|
262
|
+
# recognise (e.g. `"1.0"`); the parse-time error message names
|
|
263
|
+
# the version, so the user can correct the setting.
|
|
264
|
+
TARGET_RUBY_FORMAT = /\A(?:\d+\.\d+(?:\.\d+)?|latest)\z/
|
|
265
|
+
private_constant :TARGET_RUBY_FORMAT
|
|
266
|
+
|
|
267
|
+
def coerce_target_ruby(value)
|
|
268
|
+
s = value.to_s
|
|
269
|
+
unless s.match?(TARGET_RUBY_FORMAT)
|
|
270
|
+
raise ArgumentError,
|
|
271
|
+
"target_ruby must be a version (e.g. \"3.4\", \"4.0\", \"3.4.0\") or \"latest\", got #{value.inspect}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
s.dup.freeze
|
|
275
|
+
end
|
|
276
|
+
|
|
110
277
|
# Slice 2 only accepts `:disabled` for the network policy. The
|
|
111
278
|
# YAML scalar may arrive as a String (`"disabled"`) or already
|
|
112
279
|
# as the Symbol; coerce to the canonical Symbol shape so the
|
data/lib/rigor/environment.rb
CHANGED
|
@@ -41,7 +41,7 @@ module Rigor
|
|
|
41
41
|
prism rbs
|
|
42
42
|
].freeze
|
|
43
43
|
|
|
44
|
-
attr_reader :class_registry, :rbs_loader
|
|
44
|
+
attr_reader :class_registry, :rbs_loader, :plugin_registry
|
|
45
45
|
|
|
46
46
|
# @param class_registry [Rigor::Environment::ClassRegistry]
|
|
47
47
|
# @param rbs_loader [Rigor::Environment::RbsLoader, nil] when nil the
|
|
@@ -50,9 +50,17 @@ module Rigor
|
|
|
50
50
|
# wires the shared core loader, which is itself lazy: requesting an
|
|
51
51
|
# environment instance does NOT load RBS until a method or class
|
|
52
52
|
# query actually consults the loader.
|
|
53
|
-
|
|
53
|
+
# @param plugin_registry [Rigor::Plugin::Registry, nil] v0.1.1
|
|
54
|
+
# Track 2 slice 7. The per-run plugin registry the
|
|
55
|
+
# inference engine consults at call sites for plugin
|
|
56
|
+
# `#flow_contribution_for` overrides. When nil (the
|
|
57
|
+
# default), no plugin-level return-type contribution
|
|
58
|
+
# participates — useful for tests, the `Environment.default`
|
|
59
|
+
# facade, and analyses that don't load plugins.
|
|
60
|
+
def initialize(class_registry: ClassRegistry.default, rbs_loader: nil, plugin_registry: nil)
|
|
54
61
|
@class_registry = class_registry
|
|
55
62
|
@rbs_loader = rbs_loader
|
|
63
|
+
@plugin_registry = plugin_registry
|
|
56
64
|
freeze
|
|
57
65
|
end
|
|
58
66
|
|
|
@@ -82,7 +90,7 @@ module Rigor
|
|
|
82
90
|
# reflection artefacts) consult the cache. Pass `nil` (the
|
|
83
91
|
# default) to skip caching for this environment.
|
|
84
92
|
# @return [Rigor::Environment]
|
|
85
|
-
def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil)
|
|
93
|
+
def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil, plugin_registry: nil)
|
|
86
94
|
resolved_paths = signature_paths || default_signature_paths(root)
|
|
87
95
|
merged_libraries = (DEFAULT_LIBRARIES + libraries.map(&:to_s)).uniq
|
|
88
96
|
loader = RbsLoader.new(
|
|
@@ -90,7 +98,7 @@ module Rigor
|
|
|
90
98
|
signature_paths: resolved_paths,
|
|
91
99
|
cache_store: cache_store
|
|
92
100
|
)
|
|
93
|
-
new(rbs_loader: loader)
|
|
101
|
+
new(rbs_loader: loader, plugin_registry: plugin_registry)
|
|
94
102
|
end
|
|
95
103
|
|
|
96
104
|
private
|