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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +135 -31
  3. data/lib/rigor/analysis/check_rules.rb +10 -18
  4. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +75 -0
  5. data/lib/rigor/analysis/dependency_source_inference/builder.rb +113 -0
  6. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +72 -0
  7. data/lib/rigor/analysis/dependency_source_inference/index.rb +139 -0
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +200 -0
  9. data/lib/rigor/analysis/dependency_source_inference.rb +38 -0
  10. data/lib/rigor/analysis/diagnostic.rb +0 -2
  11. data/lib/rigor/analysis/fact_store.rb +11 -3
  12. data/lib/rigor/analysis/rule_catalog.rb +2 -2
  13. data/lib/rigor/analysis/runner.rb +206 -6
  14. data/lib/rigor/builtins/imported_refinements.rb +360 -55
  15. data/lib/rigor/cache/descriptor.rb +59 -6
  16. data/lib/rigor/cache/store.rb +1 -1
  17. data/lib/rigor/cli/diff_command.rb +1 -1
  18. data/lib/rigor/cli/sig_gen_command.rb +173 -0
  19. data/lib/rigor/cli/type_of_command.rb +1 -1
  20. data/lib/rigor/cli/type_scan_renderer.rb +1 -1
  21. data/lib/rigor/cli/type_scan_report.rb +2 -2
  22. data/lib/rigor/cli.rb +9 -1
  23. data/lib/rigor/configuration/dependencies.rb +235 -0
  24. data/lib/rigor/configuration.rb +45 -11
  25. data/lib/rigor/environment.rb +47 -4
  26. data/lib/rigor/flow_contribution/conflict.rb +2 -2
  27. data/lib/rigor/flow_contribution/element.rb +1 -1
  28. data/lib/rigor/flow_contribution/fact.rb +1 -1
  29. data/lib/rigor/flow_contribution/merge_result.rb +1 -1
  30. data/lib/rigor/flow_contribution/merger.rb +7 -3
  31. data/lib/rigor/flow_contribution.rb +2 -2
  32. data/lib/rigor/inference/block_parameter_binder.rb +0 -2
  33. data/lib/rigor/inference/coverage_scanner.rb +1 -1
  34. data/lib/rigor/inference/expression_typer.rb +67 -11
  35. data/lib/rigor/inference/fallback.rb +1 -1
  36. data/lib/rigor/inference/method_dispatcher/block_folding.rb +3 -5
  37. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +0 -12
  38. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -3
  39. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +1 -1
  40. data/lib/rigor/inference/method_dispatcher/method_folding.rb +118 -0
  41. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -11
  42. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +27 -11
  43. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +0 -4
  44. data/lib/rigor/inference/method_dispatcher.rb +233 -2
  45. data/lib/rigor/inference/method_parameter_binder.rb +1 -3
  46. data/lib/rigor/inference/narrowing.rb +2 -4
  47. data/lib/rigor/inference/rbs_type_translator.rb +0 -2
  48. data/lib/rigor/inference/scope_indexer.rb +14 -9
  49. data/lib/rigor/inference/statement_evaluator.rb +70 -6
  50. data/lib/rigor/plugin/io_boundary.rb +0 -2
  51. data/lib/rigor/plugin/loader.rb +2 -2
  52. data/lib/rigor/plugin/manifest.rb +49 -7
  53. data/lib/rigor/plugin/registry.rb +11 -0
  54. data/lib/rigor/plugin/services.rb +1 -1
  55. data/lib/rigor/plugin/type_node_resolver.rb +52 -0
  56. data/lib/rigor/plugin.rb +1 -0
  57. data/lib/rigor/rbs_extended/reporter.rb +91 -0
  58. data/lib/rigor/rbs_extended.rb +131 -32
  59. data/lib/rigor/scope.rb +25 -8
  60. data/lib/rigor/sig_gen/classification.rb +36 -0
  61. data/lib/rigor/sig_gen/generator.rb +1048 -0
  62. data/lib/rigor/sig_gen/layout_index.rb +108 -0
  63. data/lib/rigor/sig_gen/method_candidate.rb +62 -0
  64. data/lib/rigor/sig_gen/observation_collector.rb +391 -0
  65. data/lib/rigor/sig_gen/observed_call.rb +62 -0
  66. data/lib/rigor/sig_gen/path_mapper.rb +116 -0
  67. data/lib/rigor/sig_gen/renderer.rb +157 -0
  68. data/lib/rigor/sig_gen/type_elaborator.rb +92 -0
  69. data/lib/rigor/sig_gen/write_result.rb +48 -0
  70. data/lib/rigor/sig_gen/writer.rb +530 -0
  71. data/lib/rigor/sig_gen.rb +25 -0
  72. data/lib/rigor/type/bound_method.rb +79 -0
  73. data/lib/rigor/type/combinator.rb +195 -2
  74. data/lib/rigor/type/constant.rb +13 -0
  75. data/lib/rigor/type/hash_shape.rb +0 -2
  76. data/lib/rigor/type/union.rb +20 -1
  77. data/lib/rigor/type.rb +1 -0
  78. data/lib/rigor/type_node/generic.rb +62 -0
  79. data/lib/rigor/type_node/identifier.rb +30 -0
  80. data/lib/rigor/type_node/indexed_access.rb +41 -0
  81. data/lib/rigor/type_node/integer_literal.rb +29 -0
  82. data/lib/rigor/type_node/name_scope.rb +52 -0
  83. data/lib/rigor/type_node/resolver_chain.rb +56 -0
  84. data/lib/rigor/type_node/string_literal.rb +29 -0
  85. data/lib/rigor/type_node/symbol_literal.rb +28 -0
  86. data/lib/rigor/type_node/union.rb +42 -0
  87. data/lib/rigor/type_node.rb +29 -0
  88. data/lib/rigor/version.rb +1 -1
  89. data/lib/rigor.rb +2 -0
  90. data/sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs +10 -0
  91. data/sig/rigor/analysis/check_rules/dead_assignment_collector.rbs +10 -0
  92. data/sig/rigor/analysis/dependency_source_inference/gem_resolver.rbs +25 -0
  93. data/sig/rigor/analysis/dependency_source_inference/index.rbs +9 -0
  94. data/sig/rigor/cli/diff_command.rbs +4 -0
  95. data/sig/rigor/cli/explain_command.rbs +4 -0
  96. data/sig/rigor/cli/sig_gen_command.rbs +4 -0
  97. data/sig/rigor/cli/type_scan_command.rbs +3 -0
  98. data/sig/rigor/environment.rbs +6 -2
  99. data/sig/rigor/inference/builtins/method_catalog.rbs +4 -0
  100. data/sig/rigor/inference/builtins/numeric_catalog.rbs +3 -0
  101. data/sig/rigor/inference/builtins.rbs +2 -0
  102. data/sig/rigor/plugin/access_denied_error.rbs +3 -0
  103. data/sig/rigor/plugin/base.rbs +6 -0
  104. data/sig/rigor/plugin/fact_store.rbs +11 -0
  105. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  106. data/sig/rigor/plugin/load_error.rbs +6 -0
  107. data/sig/rigor/plugin/loader.rbs +20 -0
  108. data/sig/rigor/plugin/manifest.rbs +9 -0
  109. data/sig/rigor/plugin/registry.rbs +3 -0
  110. data/sig/rigor/plugin/services.rbs +3 -0
  111. data/sig/rigor/plugin/trust_policy.rbs +4 -0
  112. data/sig/rigor/plugin/type_node_resolver.rbs +3 -0
  113. data/sig/rigor/plugin.rbs +8 -0
  114. data/sig/rigor/scope.rbs +4 -2
  115. data/sig/rigor/type.rbs +28 -6
  116. 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
@@ -61,7 +61,7 @@ module Rigor
61
61
  options
62
62
  end
63
63
 
64
- def execute(target:, options:) # rubocop:disable Metrics/AbcSize
64
+ def execute(target:, options:)
65
65
  file, line, column = target
66
66
  return 1 unless file_exists?(file)
67
67
 
@@ -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 # rubocop:disable Metrics/ClassLength
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 = Data.define(
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
- ) do
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
@@ -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] = if merged.key?(key) && merged[key].is_a?(Hash) && value.is_a?(Hash)
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
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
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, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
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
 
@@ -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
- def initialize(class_registry: ClassRegistry.default, rbs_loader: nil, plugin_registry: nil)
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, plugin_registry: 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(rbs_loader: loader, plugin_registry: plugin_registry)
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 = Data.define(:target, :edge, :kind, :reason, :provenances, :message) do
32
- def initialize(target:, edge:, kind:, reason:, provenances:, message:) # rubocop:disable Metrics/ParameterLists
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 " \