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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -2
  3. data/data/builtins/ruby_core/array.yml +6 -6
  4. data/data/builtins/ruby_core/hash.yml +1 -1
  5. data/data/builtins/ruby_core/io.yml +3 -3
  6. data/data/builtins/ruby_core/numeric.yml +1 -1
  7. data/data/builtins/ruby_core/pathname.yml +100 -100
  8. data/data/builtins/ruby_core/proc.yml +1 -1
  9. data/data/builtins/ruby_core/time.yml +3 -3
  10. data/lib/rigor/analysis/runner.rb +88 -5
  11. data/lib/rigor/builtins/regex_refinement.rb +104 -0
  12. data/lib/rigor/cli/type_of_command.rb +3 -3
  13. data/lib/rigor/cli/type_scan_command.rb +4 -4
  14. data/lib/rigor/cli.rb +11 -4
  15. data/lib/rigor/configuration.rb +177 -10
  16. data/lib/rigor/environment.rb +12 -4
  17. data/lib/rigor/inference/expression_typer.rb +3 -1
  18. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
  19. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +43 -2
  20. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
  21. data/lib/rigor/inference/method_dispatcher.rb +50 -1
  22. data/lib/rigor/inference/narrowing.rb +150 -6
  23. data/lib/rigor/inference/scope_indexer.rb +49 -15
  24. data/lib/rigor/inference/statement_evaluator.rb +29 -0
  25. data/lib/rigor/plugin/base.rb +43 -0
  26. data/lib/rigor/plugin/fact_store.rb +92 -0
  27. data/lib/rigor/plugin/load_error.rb +14 -2
  28. data/lib/rigor/plugin/loader.rb +116 -0
  29. data/lib/rigor/plugin/manifest.rb +75 -6
  30. data/lib/rigor/plugin/services.rb +14 -2
  31. data/lib/rigor/plugin.rb +1 -0
  32. data/lib/rigor/trinary.rb +1 -1
  33. data/lib/rigor/type/integer_range.rb +6 -2
  34. data/lib/rigor/version.rb +1 -1
  35. data/sig/rigor/environment.rbs +3 -2
  36. data/sig/rigor.rbs +8 -2
  37. metadata +3 -1
@@ -277,7 +277,7 @@ classes:
277
277
  singleton_methods: {}
278
278
  undefined: []
279
279
  SystemStackError:
280
- parent: rb_eException
280
+ parent: Exception
281
281
  defined_at: references/ruby/proc.c:4593
282
282
  includes: []
283
283
  constants: {}
@@ -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: unknown
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: unknown
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: unknown
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: Configuration::DEFAULT_PATH }
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: Configuration::DEFAULT_PATH }
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
- cache_root = ".rigor/cache"
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
- config: Configuration::DEFAULT_PATH,
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: Configuration::DEFAULT_PATH
182
+ path: ".rigor.dist.yml"
176
183
  }
177
184
 
178
185
  parser = OptionParser.new do |opts|
@@ -6,10 +6,44 @@ require_relative "configuration/severity_profile"
6
6
 
7
7
  module Rigor
8
8
  class Configuration # rubocop:disable Metrics/ClassLength
9
- DEFAULT_PATH = ".rigor.yml"
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
- attr_reader :target_ruby, :paths, :plugins, :cache_path, :disabled_rules,
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
- def self.load(path = DEFAULT_PATH)
35
- data = if File.exist?(path)
36
- YAML.safe_load_file(path, aliases: false) || {}
37
- else
38
- {}
39
- end
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
- def initialize(data = DEFAULTS) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
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")).to_s
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
@@ -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
- def initialize(class_registry: ClassRegistry.default, rbs_loader: nil)
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
@@ -981,7 +981,9 @@ module Rigor
981
981
  method_name: node.name,
982
982
  arg_types: arg_types,
983
983
  block_type: block_type,
984
- environment: scope.environment
984
+ environment: scope.environment,
985
+ call_node: node,
986
+ scope: scope
985
987
  )
986
988
  return result if result
987
989