rigortype 0.1.14 → 0.1.16

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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules.rb +428 -6
  5. data/lib/rigor/analysis/diagnostic.rb +55 -3
  6. data/lib/rigor/analysis/rule_catalog.rb +80 -0
  7. data/lib/rigor/analysis/runner.rb +71 -2
  8. data/lib/rigor/analysis/worker_session.rb +3 -2
  9. data/lib/rigor/cache/descriptor.rb +6 -2
  10. data/lib/rigor/cli/plugin_command.rb +245 -0
  11. data/lib/rigor/cli/plugins_command.rb +51 -4
  12. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  13. data/lib/rigor/cli.rb +143 -5
  14. data/lib/rigor/configuration/severity_profile.rb +9 -0
  15. data/lib/rigor/environment/rbs_loader.rb +259 -1
  16. data/lib/rigor/environment.rb +8 -2
  17. data/lib/rigor/inference/budget_trace.rb +137 -0
  18. data/lib/rigor/inference/expression_typer.rb +9 -2
  19. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  20. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  21. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
  22. data/lib/rigor/inference/method_dispatcher.rb +57 -10
  23. data/lib/rigor/inference/precision_scanner.rb +60 -1
  24. data/lib/rigor/inference/scope_indexer.rb +184 -27
  25. data/lib/rigor/inference/statement_evaluator.rb +13 -8
  26. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  27. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  28. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  29. data/lib/rigor/plugin/base.rb +321 -2
  30. data/lib/rigor/plugin/box.rb +64 -0
  31. data/lib/rigor/plugin/inflector.rb +121 -0
  32. data/lib/rigor/plugin/isolation.rb +191 -0
  33. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  34. data/lib/rigor/plugin/macro.rb +1 -0
  35. data/lib/rigor/plugin/manifest.rb +120 -23
  36. data/lib/rigor/plugin/node_context.rb +62 -0
  37. data/lib/rigor/plugin/registry.rb +10 -0
  38. data/lib/rigor/plugin.rb +3 -0
  39. data/lib/rigor/scope.rb +27 -1
  40. data/lib/rigor/sig_gen/generator.rb +2 -3
  41. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  42. data/lib/rigor/source/literals.rb +118 -0
  43. data/lib/rigor/source/node_walker.rb +26 -0
  44. data/lib/rigor/source.rb +1 -0
  45. data/lib/rigor/triage/catalogue.rb +71 -5
  46. data/lib/rigor/type/combinator.rb +6 -1
  47. data/lib/rigor/type/union.rb +65 -1
  48. data/lib/rigor/version.rb +1 -1
  49. data/lib/rigor.rb +1 -0
  50. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  51. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  52. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  53. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  54. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  55. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  56. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  57. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  58. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  62. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  63. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  66. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  67. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  68. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  69. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  70. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  71. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  72. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  73. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  74. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  75. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  76. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  77. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  78. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  79. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  80. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  81. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  82. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  83. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  84. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
  85. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  86. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  87. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  88. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  89. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  90. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  91. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  92. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  93. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  94. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  95. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  96. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  97. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  98. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  99. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  100. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  101. data/sig/rigor/plugin/base.rbs +58 -3
  102. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  103. data/sig/rigor/plugin/manifest.rbs +31 -1
  104. data/sig/rigor/scope.rbs +3 -0
  105. data/sig/rigor/source.rbs +12 -0
  106. data/sig/rigor.rbs +5 -0
  107. data/skills/rigor-plugin-author/SKILL.md +33 -9
  108. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +65 -26
  109. data/skills/rigor-plugin-author/references/02-walker-and-types.md +213 -80
  110. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  111. data/skills/rigor-project-init/SKILL.md +72 -7
  112. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +233 -19
  113. metadata +53 -2
  114. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "box"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ # ADR-39 slice 5 — the selectable isolation strategy for target-library
8
+ # invocation. A plugin invokes a pure method on a trusted target
9
+ # library (e.g. `ActiveSupport::Inflector.pluralize("post")`) through
10
+ # {.call}; how much the invocation is isolated from Rigor's own process
11
+ # is a **configurable strategy** (`RIGOR_PLUGIN_ISOLATION` env; the
12
+ # `exe/rigor` launcher maps `.rigor.yml`'s `plugins_isolation:` onto it
13
+ # before re-exec). Three backends behind one interface:
14
+ #
15
+ # - `none` (**default**) — load into the main space and call directly.
16
+ # Lowest cost; no isolation. Fine for the common case because the
17
+ # invoked library is trusted + pure.
18
+ # - `ruby_box` — call inside a {Box} (`Ruby::Box`, `RUBY_BOX=1`). Isolates
19
+ # core-class monkey-patches + lets gem versions coexist, but a native
20
+ # crash in the boxed work still takes the process down (in-process).
21
+ # - `process` — call in a forked worker ({Process}); returns data over a
22
+ # pipe. The strongest: a child crash (even `SIGSEGV`) is contained —
23
+ # the parent survives and declines. Higher cost (fork + IPC).
24
+ #
25
+ # All three answer with the method's return value, or raise
26
+ # {Unavailable} (never approximate) when the target library cannot be
27
+ # reached in the chosen strategy — the caller's per-plugin rescue turns
28
+ # that into silence, never a wrong fact.
29
+ module Isolation
30
+ class Unavailable < StandardError
31
+ end
32
+
33
+ STRATEGIES = %w[none ruby_box process].freeze
34
+
35
+ module_function
36
+
37
+ # The default strategy. `process` (a crash-contained forked worker)
38
+ # is the default: it isolates the target library's monkey-patches +
39
+ # crashes from Rigor with no in-process contamination, and forks a
40
+ # single persistent worker (not one per call). It falls back to
41
+ # `none` where fork is unavailable (see {#backend}).
42
+ DEFAULT = "process"
43
+
44
+ # The configured strategy name (`RIGOR_PLUGIN_ISOLATION`), defaulting
45
+ # to {DEFAULT} for any unset / unrecognised value.
46
+ def strategy_name
47
+ name = ENV["RIGOR_PLUGIN_ISOLATION"].to_s
48
+ STRATEGIES.include?(name) ? name : DEFAULT
49
+ end
50
+
51
+ # Invokes `receiver.method(*args)` on a target library, requiring
52
+ # `feature` first, under the configured isolation strategy. `receiver`
53
+ # is a constant name (String), `method` a Symbol from the caller's
54
+ # allow-list, and `args` simple, Marshal-able / inspectable values
55
+ # (Strings) — never free input. Returns the result, or raises
56
+ # {Unavailable}.
57
+ def call(feature:, receiver:, method:, args:)
58
+ backend.call(feature: feature, receiver: receiver, method: method, args: args)
59
+ end
60
+
61
+ # The backend module for the configured strategy. `process`
62
+ # (including the default) falls back to `Direct` where `fork` is
63
+ # unavailable (Windows / JRuby) so inflection still works rather than
64
+ # silently degrading — the libraries are trusted + pure, so the
65
+ # main-space fallback is acceptable when no fork-based isolation can
66
+ # be had.
67
+ def backend
68
+ case strategy_name
69
+ when "ruby_box" then RubyBox
70
+ when "none" then Direct
71
+ else Process.available? ? Process : Direct
72
+ end
73
+ end
74
+
75
+ # `none` — load the trusted library into the main space and call it
76
+ # directly. No isolation; lowest cost; the current default behaviour.
77
+ module Direct
78
+ module_function
79
+
80
+ def call(feature:, receiver:, method:, args:)
81
+ require feature
82
+ Object.const_get(receiver).public_send(method, *args)
83
+ rescue LoadError, NameError => e
84
+ raise Unavailable, "#{receiver} could not be loaded (#{e.class}: #{e.message})"
85
+ end
86
+ end
87
+
88
+ # `ruby_box` — call inside the shared {Box}. The expression is built
89
+ # from the fixed `receiver` / allow-listed `method` and `inspect`-ed
90
+ # args (safe Ruby literals), so the box's `eval` carries no free
91
+ # input.
92
+ module RubyBox
93
+ module_function
94
+
95
+ def call(feature:, receiver:, method:, args:)
96
+ raise Unavailable, "ruby_box isolation requested but Ruby::Box is not active (RUBY_BOX=1)" unless Box.enabled?
97
+ raise Unavailable, "#{feature} could not be loaded into the Ruby::Box" unless Box.require_feature(feature)
98
+
99
+ # `receiver` is a fixed constant name and `method` an allow-listed
100
+ # symbol; args are rendered via `inspect` (safe Ruby literals), so
101
+ # the expression is e.g. `ActiveSupport::Inflector.pluralize("x")`
102
+ # — no free input reaches the box's eval.
103
+ rendered = args.map(&:inspect).join(", ")
104
+ expression = "#{receiver}.#{method}(#{rendered})"
105
+ Box.eval(expression)
106
+ end
107
+ end
108
+
109
+ # `process` — run the call in a forked worker so a crash (even a C
110
+ # extension `SIGSEGV`) is contained: the parent detects the dead
111
+ # worker (broken pipe / EOF) and declines instead of dying. A single
112
+ # persistent worker handles all calls over a Marshal pipe pair.
113
+ module Process
114
+ module_function
115
+
116
+ # Whether fork-based isolation can run on this platform.
117
+ def available?
118
+ ::Process.respond_to?(:fork)
119
+ end
120
+
121
+ def call(feature:, receiver:, method:, args:)
122
+ raise Unavailable, "process isolation unavailable: fork is not supported" unless available?
123
+
124
+ status, value = exchange([feature, receiver, method, args])
125
+ raise Unavailable, "process isolation worker error: #{value}" if status == :error
126
+
127
+ value
128
+ rescue Unavailable
129
+ raise
130
+ rescue StandardError => e
131
+ # A dead worker surfaces as EOFError (Marshal.load) or Errno::EPIPE
132
+ # (Marshal.dump) — both StandardError. The crash is contained: the
133
+ # parent resets the worker (respawn next call) and declines.
134
+ @worker = nil
135
+ raise Unavailable, "process isolation worker failed (#{e.class})"
136
+ end
137
+
138
+ # Sends one request to the persistent worker and reads its reply.
139
+ def exchange(request)
140
+ w = worker
141
+ Marshal.dump(request, w[:req])
142
+ w[:req].flush
143
+ Marshal.load(w[:res]) # rubocop:disable Security/MarshalLoad -- worker output, not untrusted input
144
+ end
145
+
146
+ def worker
147
+ @worker ||= spawn_worker
148
+ end
149
+
150
+ def spawn_worker
151
+ req_r, req_w = IO.pipe
152
+ res_r, res_w = IO.pipe
153
+ pid = fork_worker(req_r, req_w, res_r, res_w)
154
+ req_r.close
155
+ res_w.close
156
+ { pid: pid, req: req_w, res: res_r }
157
+ end
158
+
159
+ def fork_worker(req_r, req_w, res_r, res_w)
160
+ ::Process.fork do
161
+ req_w.close
162
+ res_r.close
163
+ run_worker_loop(req_r, res_w)
164
+ end
165
+ end
166
+
167
+ # The child loop: read a `[feature, receiver, method, args]`
168
+ # request, require + call, and write `[:ok, result]` or
169
+ # `[:error, message]`. EOF (parent gone) ends the loop.
170
+ def run_worker_loop(req_r, res_w)
171
+ loop do
172
+ feature, receiver, method, args = Marshal.load(req_r) # rubocop:disable Security/MarshalLoad -- parent input
173
+ reply =
174
+ begin
175
+ require feature
176
+ [:ok, Object.const_get(receiver).public_send(method, *args)]
177
+ rescue StandardError, LoadError => e
178
+ [:error, "#{e.class}: #{e.message}"]
179
+ end
180
+ Marshal.dump(reply, res_w)
181
+ res_w.flush
182
+ end
183
+ rescue EOFError
184
+ # parent closed the request pipe — exit quietly
185
+ ensure
186
+ exit!(0)
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ module Macro
6
+ # ADR-36 — the nested-class emission tier of the ADR-16
7
+ # macro substrate. Where Tier C ({HeredocTemplate}) synthesises
8
+ # *methods* on the calling class, this tier synthesises *nested
9
+ # subclasses* declared by an enum-shaped block DSL.
10
+ #
11
+ # Motivating shape — Mangrove's `Enum`:
12
+ #
13
+ # class Shape
14
+ # extend Mangrove::Enum
15
+ # variants do
16
+ # variant Circle, Float
17
+ # variant Rectangle, Float
18
+ # end
19
+ # end
20
+ #
21
+ # Each `variant <Const>, <Type>` row is meant to mint a nested
22
+ # subclass `Shape::Circle < Shape` carrying `#inner : <Type>`.
23
+ # Mangrove builds this at runtime via `const_missing` +
24
+ # `class_eval`; the substrate replays the same contract
25
+ # statically so `Shape::Circle.new(1.0).inner` resolves the
26
+ # payload to `Float` instead of `call.undefined-constant` /
27
+ # `Dynamic[Top]`.
28
+ #
29
+ # ## Authoring shape
30
+ #
31
+ # nested_class_templates: [
32
+ # Rigor::Plugin::Macro::NestedClassTemplate.new(
33
+ # receiver_constraint: "Mangrove::Enum", # `extend`-ed marker module
34
+ # block_method: :variants, # the enclosing DSL block
35
+ # variant_method: :variant, # each declaration call
36
+ # name_arg_position: 0, # constant arg → nested class
37
+ # inner_arg_position: 1, # type arg → `#inner` return
38
+ # inner_reader: :inner # the payload reader name
39
+ # )
40
+ # ]
41
+ #
42
+ # ## Fields
43
+ #
44
+ # - `receiver_constraint` — fully-qualified module name (String)
45
+ # the enclosing class must `extend` for the block to be
46
+ # recognised (e.g. `"Mangrove::Enum"`).
47
+ # - `block_method` — Symbol naming the enclosing DSL block
48
+ # (`:variants`).
49
+ # - `variant_method` — Symbol naming each declaration call
50
+ # inside the block (`:variant`).
51
+ # - `name_arg_position` — Integer (default 0): the argument
52
+ # index whose literal **constant** names the nested subclass.
53
+ # - `inner_arg_position` — Integer (default 1): the argument
54
+ # index whose type expression becomes the `#inner` reader's
55
+ # return type. Slice A resolves a constant type argument
56
+ # (`Float`, `String`); non-constant inner shapes (shape
57
+ # hashes) degrade to `Dynamic[Top]`.
58
+ # - `inner_reader` — Symbol (default `:inner`): the payload
59
+ # reader synthesised on each variant subclass.
60
+ #
61
+ # ## Floor / ceiling per ADR-16 WD13 + ADR-36 WD4
62
+ #
63
+ # Slice A ships the floor: each variant constant resolves as a
64
+ # class (so `<Variant>.new(...)` and the constant reference
65
+ # type), and the `#inner` reader resolves to the declared inner
66
+ # type. The `sealed`-parent fact + `is_a?` cross-variant
67
+ # exhaustive narrowing (ADR-36 WD3) is the ceiling, deferred —
68
+ # it needs the synthetic-class hierarchy threaded into
69
+ # `Environment#class_ordering`.
70
+ class NestedClassTemplate
71
+ attr_reader :receiver_constraint, :block_method, :variant_method,
72
+ :name_arg_position, :inner_arg_position, :inner_reader
73
+
74
+ def initialize(receiver_constraint:, block_method: :variants, variant_method: :variant,
75
+ name_arg_position: 0, inner_arg_position: 1, inner_reader: :inner)
76
+ validate_constraint!(receiver_constraint)
77
+ validate_method!(block_method, "block_method")
78
+ validate_method!(variant_method, "variant_method")
79
+ validate_position!(name_arg_position, "name_arg_position")
80
+ validate_position!(inner_arg_position, "inner_arg_position")
81
+ validate_method!(inner_reader, "inner_reader")
82
+
83
+ @receiver_constraint = receiver_constraint.dup.freeze
84
+ @block_method = block_method.to_sym
85
+ @variant_method = variant_method.to_sym
86
+ @name_arg_position = name_arg_position
87
+ @inner_arg_position = inner_arg_position
88
+ @inner_reader = inner_reader.to_sym
89
+ freeze
90
+ end
91
+
92
+ def to_h
93
+ {
94
+ "receiver_constraint" => receiver_constraint,
95
+ "block_method" => block_method.to_s,
96
+ "variant_method" => variant_method.to_s,
97
+ "name_arg_position" => name_arg_position,
98
+ "inner_arg_position" => inner_arg_position,
99
+ "inner_reader" => inner_reader.to_s
100
+ }
101
+ end
102
+
103
+ def ==(other)
104
+ other.is_a?(NestedClassTemplate) && to_h == other.to_h
105
+ end
106
+ alias eql? ==
107
+
108
+ def hash
109
+ to_h.hash
110
+ end
111
+
112
+ private
113
+
114
+ def validate_constraint!(value)
115
+ return if value.is_a?(String) && !value.empty?
116
+
117
+ raise ArgumentError,
118
+ "Plugin::Macro::NestedClassTemplate#receiver_constraint must be a non-empty String, " \
119
+ "got #{value.inspect}"
120
+ end
121
+
122
+ def validate_method!(value, label)
123
+ return if value.is_a?(Symbol) || (value.is_a?(String) && !value.empty?)
124
+
125
+ raise ArgumentError,
126
+ "Plugin::Macro::NestedClassTemplate##{label} must be a Symbol or non-empty String, " \
127
+ "got #{value.inspect}"
128
+ end
129
+
130
+ def validate_position!(value, label)
131
+ return if value.is_a?(Integer) && value >= 0
132
+
133
+ raise ArgumentError,
134
+ "Plugin::Macro::NestedClassTemplate##{label} must be a non-negative Integer, " \
135
+ "got #{value.inspect}"
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "macro/block_as_method"
4
4
  require_relative "macro/external_file"
5
5
  require_relative "macro/heredoc_template"
6
+ require_relative "macro/nested_class_template"
6
7
  require_relative "macro/trait_registry"
7
8
 
8
9
  module Rigor
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../inference/hkt_registry"
4
4
  require_relative "protocol_contract"
5
+ require_relative "additional_initializer"
5
6
 
6
7
  module Rigor
7
8
  module Plugin
@@ -40,22 +41,23 @@ module Rigor
40
41
  end
41
42
  end
42
43
 
43
- attr_reader :id, :version, :description, :protocols, :config_schema, :produces, :consumes,
44
+ attr_reader :id, :version, :description, :config_schema, :config_defaults, :produces, :consumes,
44
45
  :owns_receivers, :open_receivers, :type_node_resolvers, :block_as_methods,
45
- :heredoc_templates, :trait_registries, :external_files, :hkt_registrations,
46
- :hkt_definitions, :signature_paths, :protocol_contracts, :source_rbs_synthesizer
46
+ :heredoc_templates, :nested_class_templates, :trait_registries, :external_files,
47
+ :hkt_registrations, :hkt_definitions, :signature_paths, :protocol_contracts,
48
+ :source_rbs_synthesizer, :additional_initializers
47
49
 
48
50
  def initialize( # rubocop:disable Metrics/ParameterLists
49
51
  id:, version:,
50
- description: nil, protocols: [], config_schema: {},
52
+ description: nil, config_schema: {},
51
53
  produces: [], consumes: [], owns_receivers: [], open_receivers: [], type_node_resolvers: [],
52
- block_as_methods: [], heredoc_templates: [], trait_registries: [], external_files: [],
54
+ block_as_methods: [], heredoc_templates: [], nested_class_templates: [],
55
+ trait_registries: [], external_files: [],
53
56
  hkt_registrations: [], hkt_definitions: [], signature_paths: [], protocol_contracts: [],
54
- source_rbs_synthesizer: nil
57
+ source_rbs_synthesizer: nil, additional_initializers: []
55
58
  )
56
59
  validate_id!(id)
57
60
  validate_version!(version)
58
- validate_protocols!(protocols)
59
61
  validate_config_schema!(config_schema)
60
62
  validate_produces!(produces)
61
63
  validate_owns_receivers!(owns_receivers)
@@ -63,6 +65,7 @@ module Rigor
63
65
  validate_type_node_resolvers!(type_node_resolvers)
64
66
  validate_block_as_methods!(block_as_methods)
65
67
  validate_heredoc_templates!(heredoc_templates)
68
+ validate_nested_class_templates!(nested_class_templates)
66
69
  validate_trait_registries!(trait_registries)
67
70
  validate_external_files!(external_files)
68
71
  validate_hkt_registrations!(hkt_registrations)
@@ -70,26 +73,29 @@ module Rigor
70
73
  validate_signature_paths!(signature_paths)
71
74
  validate_protocol_contracts!(protocol_contracts)
72
75
  validate_source_rbs_synthesizer!(source_rbs_synthesizer)
76
+ validate_additional_initializers!(additional_initializers)
73
77
 
74
- assign_fields(id, version, description, protocols, config_schema, produces, consumes, owns_receivers,
78
+ assign_fields(id, version, description, config_schema, produces, consumes, owns_receivers,
75
79
  open_receivers, type_node_resolvers, block_as_methods, heredoc_templates, trait_registries,
76
80
  external_files, hkt_registrations, hkt_definitions, signature_paths, protocol_contracts,
77
81
  source_rbs_synthesizer)
82
+ assign_nested_class_templates(nested_class_templates)
83
+ assign_additional_initializers(additional_initializers)
78
84
  freeze
79
85
  end
80
86
 
81
87
  private
82
88
 
83
89
  # rubocop:disable Metrics/ParameterLists, Metrics/AbcSize
84
- def assign_fields(id, version, description, protocols, config_schema, produces, consumes, owns_receivers,
90
+ def assign_fields(id, version, description, config_schema, produces, consumes, owns_receivers,
85
91
  open_receivers, type_node_resolvers, block_as_methods, heredoc_templates, trait_registries,
86
92
  external_files, hkt_registrations, hkt_definitions, signature_paths, protocol_contracts,
87
93
  source_rbs_synthesizer)
88
94
  @id = id.dup.freeze
89
95
  @version = version.dup.freeze
90
96
  @description = description.nil? ? nil : description.to_s.dup.freeze
91
- @protocols = protocols.map(&:to_sym).freeze
92
- @config_schema = config_schema.to_h { |k, v| [k.to_s.dup.freeze, v.to_sym] }.freeze
97
+ @config_schema = config_schema.to_h { |k, v| [k.to_s.dup.freeze, schema_kind(v)] }.freeze
98
+ @config_defaults = extract_config_defaults(config_schema)
93
99
  @produces = produces.map(&:to_sym).freeze
94
100
  @consumes = coerce_consumes(consumes)
95
101
  @owns_receivers = owns_receivers.map { |c| c.to_s.dup.freeze }.freeze
@@ -105,6 +111,22 @@ module Rigor
105
111
  @protocol_contracts = protocol_contracts.dup.freeze
106
112
  @source_rbs_synthesizer = source_rbs_synthesizer
107
113
  end
114
+
115
+ # Assigned outside assign_fields (which already carries the
116
+ # maximum positional arity) — set in `initialize` before the
117
+ # final freeze. ADR-36 nested-class emission tier.
118
+ def assign_nested_class_templates(nested_class_templates)
119
+ @nested_class_templates = nested_class_templates.dup.freeze
120
+ end
121
+ private :assign_nested_class_templates
122
+
123
+ # ADR-38 — assigned outside assign_fields (which already carries
124
+ # the maximum positional arity), set in `initialize` before the
125
+ # final freeze.
126
+ def assign_additional_initializers(additional_initializers)
127
+ @additional_initializers = additional_initializers.dup.freeze
128
+ end
129
+ private :assign_additional_initializers
108
130
  # rubocop:enable Metrics/ParameterLists, Metrics/AbcSize
109
131
 
110
132
  public
@@ -137,8 +159,8 @@ module Rigor
137
159
  "id" => id,
138
160
  "version" => version,
139
161
  "description" => description,
140
- "protocols" => protocols.map(&:to_s),
141
162
  "config_schema" => config_schema.to_h { |k, v| [k, v.to_s] },
163
+ "config_defaults" => config_defaults,
142
164
  "produces" => produces.map(&:to_s),
143
165
  "consumes" => consumes.map { |c| consumption_hash(c) },
144
166
  "owns_receivers" => owns_receivers,
@@ -146,13 +168,15 @@ module Rigor
146
168
  "type_node_resolvers" => type_node_resolvers.map { |r| r.class.name },
147
169
  "block_as_methods" => block_as_methods.map(&:to_h),
148
170
  "heredoc_templates" => heredoc_templates.map(&:to_h),
171
+ "nested_class_templates" => nested_class_templates.map(&:to_h),
149
172
  "trait_registries" => trait_registries.map(&:to_h),
150
173
  "external_files" => external_files.map(&:to_h),
151
174
  "hkt_registrations" => hkt_registrations.map(&:to_h),
152
175
  "hkt_definitions" => hkt_definitions.map { |d| { "uri" => d.uri, "params" => d.params } },
153
176
  "signature_paths" => signature_paths,
154
177
  "protocol_contracts" => protocol_contracts.map(&:to_h),
155
- "source_rbs_synthesizer" => source_rbs_synthesizer&.class&.name
178
+ "source_rbs_synthesizer" => source_rbs_synthesizer&.class&.name,
179
+ "additional_initializers" => additional_initializers.map(&:to_h)
156
180
  }
157
181
  end
158
182
 
@@ -180,27 +204,70 @@ module Rigor
180
204
  raise ArgumentError, "plugin manifest version must be a non-empty String, got #{version.inspect}"
181
205
  end
182
206
 
183
- def validate_protocols!(protocols)
184
- return if protocols.is_a?(Array) && protocols.all? { |p| p.is_a?(Symbol) || p.is_a?(String) }
185
-
186
- raise ArgumentError, "plugin manifest protocols must be an Array of Symbol/String, got #{protocols.inspect}"
187
- end
188
-
189
207
  def validate_config_schema!(schema)
190
208
  unless schema.is_a?(Hash)
191
209
  raise ArgumentError,
192
210
  "plugin manifest config_schema must be a Hash, got #{schema.inspect}"
193
211
  end
194
212
 
195
- schema.each_value do |kind|
196
- next if VALID_VALUE_KINDS.include?(kind.to_sym)
213
+ schema.each do |key, value|
214
+ kind = schema_kind(value)
215
+ unless VALID_VALUE_KINDS.include?(kind)
216
+ raise ArgumentError,
217
+ "plugin manifest config_schema value kind must be one of " \
218
+ "#{VALID_VALUE_KINDS.inspect}, got #{value.inspect}"
219
+ end
220
+
221
+ next unless schema_default?(value)
222
+
223
+ default = schema_default(value)
224
+ next if value_matches?(default, kind)
225
+
226
+ raise ArgumentError,
227
+ "plugin manifest config_schema default for #{key.to_s.inspect} expected " \
228
+ "#{kind}, got #{default.class}"
229
+ end
230
+ end
231
+
232
+ # ADR-40 — a `config_schema` value is either a bare kind
233
+ # (`Symbol`/`String`, the original form) or a `{kind:, default:}`
234
+ # Hash. These three helpers read whichever shape was given.
235
+ def schema_kind(value)
236
+ if value.is_a?(Hash)
237
+ kind = value[:kind] || value["kind"]
238
+ if kind.nil?
239
+ raise ArgumentError,
240
+ "plugin manifest config_schema entry Hash must declare :kind, got #{value.inspect}"
241
+ end
197
242
 
243
+ kind.to_sym
244
+ elsif value.is_a?(Symbol) || value.is_a?(String)
245
+ value.to_sym
246
+ else
198
247
  raise ArgumentError,
199
- "plugin manifest config_schema value kind must be one of " \
200
- "#{VALID_VALUE_KINDS.inspect}, got #{kind.inspect}"
248
+ "plugin manifest config_schema value must be a kind Symbol/String or a " \
249
+ "{kind:, default:} Hash, got #{value.inspect}"
201
250
  end
202
251
  end
203
252
 
253
+ def schema_default?(value)
254
+ value.is_a?(Hash) && (value.key?(:default) || value.key?("default"))
255
+ end
256
+
257
+ def schema_default(value)
258
+ value.key?(:default) ? value[:default] : value["default"]
259
+ end
260
+
261
+ def extract_config_defaults(schema)
262
+ schema.each_with_object({}) do |(key, value), acc|
263
+ acc[key.to_s.dup.freeze] = value_default_frozen(schema_default(value)) if schema_default?(value)
264
+ end.freeze
265
+ end
266
+
267
+ def value_default_frozen(default)
268
+ default.frozen? ? default : default.dup.freeze
269
+ end
270
+
204
271
  def value_matches?(value, kind)
205
272
  case kind
206
273
  when :string then value.is_a?(String)
@@ -294,6 +361,20 @@ module Rigor
294
361
  "Rigor::Plugin::Macro::HeredocTemplate instances, got #{entries.inspect}"
295
362
  end
296
363
 
364
+ # ADR-36 — `nested_class_templates:` declares the
365
+ # nested-class emission tier (enum-shaped block DSLs that mint
366
+ # nested subclasses, e.g. Mangrove's `variants do variant
367
+ # Const, Type end`). The scanner synthesises the variant
368
+ # subclasses + their `#inner` reader through the existing
369
+ # `SyntheticMethodIndex` primitive.
370
+ def validate_nested_class_templates!(entries)
371
+ return if entries.is_a?(Array) && entries.all?(Macro::NestedClassTemplate)
372
+
373
+ raise ArgumentError,
374
+ "plugin manifest nested_class_templates must be an Array of " \
375
+ "Rigor::Plugin::Macro::NestedClassTemplate instances, got #{entries.inspect}"
376
+ end
377
+
297
378
  # ADR-16 slice 3a — `trait_registries:` declares the Tier B
298
379
  # substrate entries (trait-inlining via bundled module
299
380
  # registry). Slice 3a carries the declarations on the
@@ -395,6 +476,22 @@ module Rigor
395
476
  "Rigor::Plugin::ProtocolContract instances, got #{entries.inspect}"
396
477
  end
397
478
 
479
+ # ADR-38 — `additional_initializers:` declares the
480
+ # (receiver_constraint, methods) pairs whose `def`-form methods
481
+ # the engine treats like `initialize` for the read-before-write
482
+ # nil soundness gate. Each entry MUST be a
483
+ # `Rigor::Plugin::AdditionalInitializer`. The registry
484
+ # aggregator on `Plugin::Registry` flattens entries across
485
+ # loaded plugins; `Inference::ScopeIndexer` consults the set at
486
+ # its single gate.
487
+ def validate_additional_initializers!(entries)
488
+ return if entries.is_a?(Array) && entries.all?(AdditionalInitializer)
489
+
490
+ raise ArgumentError,
491
+ "plugin manifest additional_initializers must be an Array of " \
492
+ "Rigor::Plugin::AdditionalInitializer instances, got #{entries.inspect}"
493
+ end
494
+
398
495
  # ADR-32 WD4 — `source_rbs_synthesizer:` declares a callable
399
496
  # the engine invokes once per analysed Ruby source file at
400
497
  # env-build time. The callable receives a source file path
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ # ADR-37 slice 1d — the lexical context of a node, handed to a
8
+ # {Base.node_rule} block as its fifth argument. Realises the
9
+ # `ContextInfo` ADR-2 § "Scope Object" promised: the enclosing
10
+ # class / module, method, and block-DSL nesting that a per-node rule
11
+ # needs but that `Scope` (value/type facts) does not carry.
12
+ #
13
+ # The engine builds one per matching node from the descent stack
14
+ # (snapshotted, so it is safe to retain). Rules read the bits they
15
+ # need:
16
+ #
17
+ # node_rule Prism::CallNode do |node, _scope, path, _fc, context|
18
+ # action = context.enclosing_def&.name # rails-i18n
19
+ # model = context.enclosing_block(:describe) # shoulda
20
+ # …
21
+ # end
22
+ #
23
+ # `ancestors` is the full chain, outermost first, EXCLUDING the node
24
+ # itself — the general primitive; the accessors below are
25
+ # conveniences derived from it.
26
+ class NodeContext
27
+ EMPTY = nil # sentinel documented for readers; rules get a real instance
28
+
29
+ attr_reader :ancestors
30
+
31
+ def initialize(ancestors)
32
+ @ancestors = ancestors.dup.freeze
33
+ freeze
34
+ end
35
+
36
+ # The innermost enclosing `Prism::DefNode`, or nil. Its `#name`
37
+ # is the method the node sits in (rails-i18n uses it to expand a
38
+ # lazy `t('.key')` against the controller action).
39
+ def enclosing_def
40
+ ancestors.rfind { |n| n.is_a?(Prism::DefNode) }
41
+ end
42
+
43
+ # The innermost enclosing `Prism::ClassNode` / `Prism::ModuleNode`,
44
+ # or nil (actionpack uses it to resolve the controller a
45
+ # `before_action` / `render` sits in).
46
+ def enclosing_module
47
+ ancestors.rfind { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
48
+ end
49
+
50
+ # The innermost enclosing `Prism::CallNode` that carries a block
51
+ # and whose method name is `method_name` — e.g. the
52
+ # `RSpec.describe(Model) do … end` a shoulda matcher sits in.
53
+ # Returns the CallNode (so the caller can read its arguments /
54
+ # receiver), or nil.
55
+ def enclosing_block(method_name)
56
+ ancestors.rfind do |n|
57
+ n.is_a?(Prism::CallNode) && n.block && n.name == method_name
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -143,6 +143,16 @@ module Rigor
143
143
  plugins.flat_map(&:protocol_contracts)
144
144
  end
145
145
 
146
+ # ADR-38 — flat, ordered list of every loaded plugin's
147
+ # manifest-declared `Rigor::Plugin::AdditionalInitializer`
148
+ # entries. `Inference::ScopeIndexer` consults the set at its
149
+ # read-before-write nil soundness gate: a `def` whose name an
150
+ # entry covers, on a class that equals or inherits from the
151
+ # entry's `receiver_constraint`, is treated like `initialize`.
152
+ def additional_initializers
153
+ plugins.flat_map { |plugin| plugin.manifest.additional_initializers }
154
+ end
155
+
146
156
  # ADR-28 — the subset of `protocol_contracts` whose
147
157
  # `path_glob` matches `path`. Contract globs are authored
148
158
  # project-root-relative (`lib/controller/**/*.rb`); the
data/lib/rigor/plugin.rb CHANGED
@@ -11,6 +11,9 @@ require_relative "plugin/services"
11
11
  require_relative "plugin/base"
12
12
  require_relative "plugin/registry"
13
13
  require_relative "plugin/load_error"
14
+ require_relative "plugin/box"
15
+ require_relative "plugin/isolation"
16
+ require_relative "plugin/inflector"
14
17
 
15
18
  module Rigor
16
19
  module Plugin