rigortype 0.2.4 → 0.2.6

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/docs/handbook/09-plugins.md +5 -2
  3. data/docs/handbook/appendix-liskov.md +5 -3
  4. data/docs/handbook/appendix-phpstan.md +2 -2
  5. data/docs/install.md +1 -1
  6. data/docs/manual/02-cli-reference.md +58 -1
  7. data/docs/manual/06-baseline.md +12 -0
  8. data/docs/manual/11-ci.md +6 -6
  9. data/docs/manual/15-type-protection-coverage.md +29 -0
  10. data/docs/manual/plugins/rigor-minitest.md +1 -1
  11. data/docs/manual/plugins/rigor-rails-i18n.md +22 -3
  12. data/lib/rigor/analysis/incremental_session.rb +7 -2
  13. data/lib/rigor/cli/check_command.rb +4 -33
  14. data/lib/rigor/cli/check_runner_factory.rb +63 -0
  15. data/lib/rigor/cli/doctor_command.rb +295 -0
  16. data/lib/rigor/cli/plugins_command.rb +2 -2
  17. data/lib/rigor/cli/plugins_renderer.rb +1 -1
  18. data/lib/rigor/cli/protection_renderer.rb +32 -2
  19. data/lib/rigor/cli/protection_report.rb +32 -6
  20. data/lib/rigor/cli/upgrade_command.rb +25 -0
  21. data/lib/rigor/cli.rb +17 -1
  22. data/lib/rigor/flow_contribution/fact.rb +1 -1
  23. data/lib/rigor/inference/dynamic_origin.rb +67 -0
  24. data/lib/rigor/inference/expression_typer.rb +22 -10
  25. data/lib/rigor/inference/fallback.rb +2 -2
  26. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -0
  27. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +41 -2
  28. data/lib/rigor/inference/method_dispatcher.rb +19 -4
  29. data/lib/rigor/inference/mutation_widening.rb +18 -0
  30. data/lib/rigor/inference/protection_scanner.rb +6 -3
  31. data/lib/rigor/inference/statement_evaluator.rb +5 -4
  32. data/lib/rigor/plugin/base.rb +34 -7
  33. data/lib/rigor/plugin/registry.rb +1 -1
  34. data/lib/rigor/scope.rb +16 -5
  35. data/lib/rigor/version.rb +1 -1
  36. data/lib/rigor.rb +1 -0
  37. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +1 -1
  38. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +1 -1
  39. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +52 -0
  40. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +123 -8
  41. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +1 -1
  42. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +3 -3
  43. data/sig/rigor/plugin/base.rbs +2 -0
  44. data/sig/rigor/scope.rbs +3 -1
  45. data/skills/rigor-plugin-author/SKILL.md +8 -5
  46. data/skills/rigor-plugin-author/references/02-walker-and-types.md +8 -4
  47. metadata +27 -3
@@ -9,6 +9,7 @@ require_relative "../analysis/self_call_resolution_recorder"
9
9
  require_relative "block_parameter_binder"
10
10
  require_relative "body_fixpoint"
11
11
  require_relative "budget_trace"
12
+ require_relative "dynamic_origin"
12
13
  require_relative "fallback"
13
14
  require_relative "flow_tracer"
14
15
  require_relative "indexed_narrowing"
@@ -224,15 +225,20 @@ module Rigor
224
225
  def initialize(scope:, tracer: nil)
225
226
  @scope = scope
226
227
  @tracer = tracer
228
+ @typing_node = nil
227
229
  end
228
230
 
229
231
  def type_of(node)
230
- return untraced_type_of(node) unless FlowTracer.active?
231
-
232
- # `rigor trace` — bracket the recursion with enter/result events.
233
- # The tracer is observational only: the inferred type flows
234
- # through unchanged (see FlowTracer's contract).
235
- FlowTracer.trace_node(node) { untraced_type_of(node) }
232
+ previous = @typing_node
233
+ @typing_node = node
234
+ result = if FlowTracer.active?
235
+ FlowTracer.trace_node(node) { untraced_type_of(node) }
236
+ else
237
+ untraced_type_of(node)
238
+ end
239
+ result
240
+ ensure
241
+ @typing_node = previous
236
242
  end
237
243
 
238
244
  def untraced_type_of(node)
@@ -1011,11 +1017,12 @@ module Rigor
1011
1017
 
1012
1018
  def fallback_for(node, family:)
1013
1019
  inner = dynamic_top
1014
- record_fallback(node, family: family, inner_type: inner)
1020
+ record_fallback(node, family: family, inner_type: inner, origin: DynamicOrigin::UNSUPPORTED_SYNTAX)
1021
+ scope.record_dynamic_origin(node, DynamicOrigin::UNSUPPORTED_SYNTAX)
1015
1022
  inner
1016
1023
  end
1017
1024
 
1018
- def record_fallback(node, family:, inner_type:)
1025
+ def record_fallback(node, family:, inner_type:, origin: nil)
1019
1026
  return unless tracer
1020
1027
 
1021
1028
  location = node.respond_to?(:location) ? node.location : nil
@@ -1023,7 +1030,8 @@ module Rigor
1023
1030
  node_class: node.class,
1024
1031
  location: location,
1025
1032
  family: family,
1026
- inner_type: inner_type
1033
+ inner_type: inner_type,
1034
+ origin: origin
1027
1035
  )
1028
1036
  tracer.record_fallback(event)
1029
1037
  end
@@ -2128,6 +2136,7 @@ module Rigor
2128
2136
  # this signature's summary sees the floor, not the stale `bot` seed.
2129
2137
  def degrade_entangled_fixpoint(summaries, plain_signature)
2130
2138
  BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
2139
+ scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
2131
2140
  summaries[plain_signature][:assumption] = Type::Combinator.untyped
2132
2141
  Type::Combinator.untyped
2133
2142
  end
@@ -2152,6 +2161,7 @@ module Rigor
2152
2161
  # Out of iterations and still unstable — collapse to today's
2153
2162
  # widening behaviour.
2154
2163
  BudgetTrace.hit(BudgetTrace::RECURSION_FIXPOINT_CAP)
2164
+ scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
2155
2165
  summaries[plain_signature][:assumption] = Type::Combinator.untyped
2156
2166
  return Type::Combinator.untyped
2157
2167
  end
@@ -2244,6 +2254,7 @@ module Rigor
2244
2254
  return type unless would_have_been_guarded && !fully_value_pinned?(type)
2245
2255
 
2246
2256
  BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
2257
+ scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
2247
2258
  # ADR-55 WD1 clamp: a guarded extended frame whose body is non-pinned
2248
2259
  # must be byte-identical to the plain guard's `untyped`. This path
2249
2260
  # deliberately does NOT route to the in-progress fixpoint summary:
@@ -2363,7 +2374,8 @@ module Rigor
2363
2374
  locals: locals.freeze,
2364
2375
  self_type: receiver,
2365
2376
  discovery: scope.discovery,
2366
- struct_fold_safe_locals: struct_fold_safe_locals_for(def_node.body)
2377
+ struct_fold_safe_locals: struct_fold_safe_locals_for(def_node.body),
2378
+ dynamic_origins: scope.dynamic_origins
2367
2379
  )
2368
2380
  end
2369
2381
 
@@ -20,8 +20,8 @@ module Rigor
20
20
  # - inner_type: the Rigor::Type returned to the caller (currently
21
21
  # always Dynamic[Top]; later slices may carry richer fallback
22
22
  # types).
23
- class Fallback < Data.define(:node_class, :location, :family, :inner_type)
24
- def initialize(node_class:, location:, family:, inner_type:)
23
+ class Fallback < Data.define(:node_class, :location, :family, :inner_type, :origin)
24
+ def initialize(node_class:, location:, family:, inner_type:, origin: nil)
25
25
  raise ArgumentError, "node_class must be a Class, got #{node_class.class}" unless node_class.is_a?(Class)
26
26
 
27
27
  unless FALLBACK_FAMILIES.include?(family)
@@ -218,11 +218,27 @@ module Rigor
218
218
  # is what ultimately limits how wide an inferred type gets.
219
219
  UNION_FOLD_OUTPUT_LIMIT = 8
220
220
 
221
+ # ADR-78 — reflexive over-fold guard.
222
+ # Reflective dispatch (`public_send` / `send` / `__send__`) must
223
+ # NOT constant-fold unless the method-name argument is itself a
224
+ # value-pinned literal `Constant[Symbol]`. With a runtime-variable
225
+ # method name the dispatched method is not statically determined,
226
+ # so the call degrades to the RBS result (`untyped`) — exactly as
227
+ # it does without the guard, but explicit so a later shape-carrier
228
+ # preservation tier (ADR-76 WD2) cannot surface an over-fold as a
229
+ # spurious `flow.always-truthy-condition`.
230
+ REFLECTIVE_SEND_METHODS = %i[public_send send __send__].to_set.freeze
231
+
221
232
  # @return [Rigor::Type::Constant, Rigor::Type::Union, Rigor::Type::IntegerRange, nil]
222
233
  def try_dispatch(context)
223
234
  receiver = context.receiver
224
235
  method_name = context.method_name
225
236
  args = context.args
237
+
238
+ if REFLECTIVE_SEND_METHODS.include?(method_name)
239
+ first_arg = args.first
240
+ return nil unless first_arg.is_a?(Type::Constant) && first_arg.value.is_a?(Symbol)
241
+ end
226
242
  # v0.0.7 — `String#%` against a `Tuple` / `HashShape`
227
243
  # argument runs Ruby's format-string engine when both
228
244
  # sides are statically constant. The standard
@@ -102,7 +102,11 @@ module Rigor
102
102
  find_index: :tuple_find_index,
103
103
  rindex: :tuple_rindex,
104
104
  flatten: :tuple_flatten,
105
- join: :tuple_join
105
+ join: :tuple_join,
106
+ freeze: :shape_self,
107
+ dup: :shape_self,
108
+ clone: :shape_self,
109
+ itself: :shape_self
106
110
  }.freeze
107
111
 
108
112
  # Byte cap on a folded `tuple.join` result — a huge tuple times a
@@ -151,7 +155,23 @@ module Rigor
151
155
  :< => :hash_compare,
152
156
  :<= => :hash_compare,
153
157
  :> => :hash_compare,
154
- :>= => :hash_compare
158
+ :>= => :hash_compare,
159
+ # ADR-76 WD2 / ADR-78 WD3 — pure self-returners preserve the
160
+ # `HashShape` carrier instead of degrading to the nominal `Hash`
161
+ # via the RBS `() -> self` signature, so
162
+ # `MESSAGES = {…}.freeze; MESSAGES[reason]` folds the value union
163
+ # rather than reading `Dynamic`. `dup` / `clone` produce a fresh
164
+ # object, but Rigor's shape carriers are immutable values, so
165
+ # preserving the carrier is sound for reads; a later in-place
166
+ # mutation routes through `MutationWidening`. (The `Tuple` table
167
+ # carries the same four entries; both became safe once the
168
+ # block-form over-fold guard landed in `try_dispatch` — ADR-78
169
+ # WD1 — so `CONST = [...].freeze; CONST.any? { … }` no longer
170
+ # folds the no-block result and fires a reflexive always-truthy.)
171
+ freeze: :shape_self,
172
+ dup: :shape_self,
173
+ clone: :shape_self,
174
+ itself: :shape_self
155
175
  }.freeze
156
176
 
157
177
  # @return [Rigor::Type, nil] the precise element/value type, or
@@ -189,6 +209,17 @@ module Rigor
189
209
  method_name = context.method_name
190
210
  args = context.args
191
211
  args ||= []
212
+ # ADR-78 WD1 — every shape handler folds *no-block* semantics; none
213
+ # evaluates a passed block. So a block-form call (`tuple.any? { … }`,
214
+ # `tuple.sum { … }`, `tuple.count { … }`) must NOT fold the no-block
215
+ # result — doing so ignores the block (an over-fold: `any? { false }`
216
+ # would still fold `Constant[true]`). Declining defers to BlockFolding
217
+ # / RBS. This is the over-fold class the Tuple shape-carrier
218
+ # preservation (ADR-76 WD2) surfaced as reflexive `always-truthy` on
219
+ # `CONST = [...].freeze; CONST.any? { … }`; fixing it at the fold (not
220
+ # the rule) unblocks that preservation.
221
+ return nil unless context.block_type.nil?
222
+
192
223
  handler = RECEIVER_HANDLERS[receiver.class]
193
224
  return nil unless handler
194
225
 
@@ -236,6 +267,14 @@ module Rigor
236
267
  send(handler, shape, method_name, args)
237
268
  end
238
269
 
270
+ # ADR-76 WD2 / ADR-78 WD3 — a pure self-returner
271
+ # (`freeze` / `dup` / `clone` / `itself`) returns the receiver
272
+ # carrier unchanged, preserving the shape that the nominal
273
+ # `() -> self` RBS signature would otherwise drop.
274
+ def shape_self(carrier, _method_name, _args)
275
+ carrier
276
+ end
277
+
239
278
  def dispatch_nominal_size(nominal, method_name, args)
240
279
  projection = nominal_projection(nominal, method_name, args)
241
280
  return projection if projection
@@ -6,6 +6,7 @@ require_relative "../flow_contribution"
6
6
  require_relative "../flow_contribution/merger"
7
7
  require_relative "../builtins/hkt_builtins"
8
8
  require_relative "../builtins/static_return_refinements"
9
+ require_relative "dynamic_origin"
9
10
  require_relative "flow_tracer"
10
11
  require_relative "method_dispatcher/call_context"
11
12
  require_relative "method_dispatcher/constant_folding"
@@ -83,7 +84,7 @@ module Rigor
83
84
  result
84
85
  end
85
86
 
86
- def resolve(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
87
+ def resolve(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
87
88
  block_type: nil, environment: nil,
88
89
  call_node: nil, scope: nil)
89
90
  return nil if receiver_type.nil?
@@ -114,7 +115,12 @@ module Rigor
114
115
  # are supplied — the dispatcher's own internal callers
115
116
  # (per-element block fold, etc.) skip this tier.
116
117
  plugin_result = try_plugin_contribution(call_node, scope, receiver_type)
117
- return plugin_result if plugin_result
118
+ if plugin_result
119
+ if plugin_result.is_a?(Type::Dynamic)
120
+ scope&.record_dynamic_origin(call_node, DynamicOrigin::FRAMEWORK_DSL_BOUNDARY)
121
+ end
122
+ return plugin_result
123
+ end
118
124
 
119
125
  # ADR-20 slice 3 — Rigor-bundled HKT-builtin return-
120
126
  # type tier. Sits ABOVE `RbsDispatch.try_dispatch` so
@@ -145,6 +151,9 @@ module Rigor
145
151
  rbs_result = RbsDispatch.try_dispatch(context)
146
152
  if rbs_result
147
153
  record_boundary_cross_if_applicable(receiver_type, method_name, rbs_result, environment)
154
+ if rbs_result.is_a?(Type::Dynamic) && rbs_result.static_facet.is_a?(Type::Top)
155
+ scope&.record_dynamic_origin(call_node, DynamicOrigin::EXPLICIT_UNTYPED)
156
+ end
148
157
  return rbs_result
149
158
  end
150
159
 
@@ -174,7 +183,10 @@ module Rigor
174
183
  # `Dynamic[top]` so the patched call resolves
175
184
  # cross-file without `call.undefined-method`.
176
185
  patched_result = try_project_patched_method(receiver_type, method_name, environment)
177
- return patched_result if patched_result
186
+ if patched_result
187
+ scope&.record_dynamic_origin(call_node, DynamicOrigin::EXTERNAL_GEM_WITHOUT_RBS)
188
+ return patched_result
189
+ end
178
190
 
179
191
  # ADR-10 slice 2b-ii — dependency-source inference tier.
180
192
  # Sits BELOW RBS dispatch (RBS / RBS::Inline / generated
@@ -186,7 +198,10 @@ module Rigor
186
198
  # dynamic-origin envelope; per-method return-type
187
199
  # precision is queued for a later slice.
188
200
  dep_source_result = try_dependency_source(receiver_type, method_name, environment)
189
- return dep_source_result if dep_source_result
201
+ if dep_source_result
202
+ scope&.record_dynamic_origin(call_node, DynamicOrigin::EXTERNAL_GEM_WITHOUT_RBS)
203
+ return dep_source_result
204
+ end
190
205
 
191
206
  # v0.1.3 — discovered-method dispatch tier. When the
192
207
  # receiver class has no RBS BUT scope_indexer recorded
@@ -102,8 +102,22 @@ module Rigor
102
102
  replace
103
103
  ].to_set.freeze
104
104
 
105
+ # Methods that return the receiver (or a shallow copy) and
106
+ # cannot mutate it. They must not trigger widening or any
107
+ # other receiver-fact invalidation. The list is intentionally
108
+ # narrow — only methods whose purity is unconditional and
109
+ # whose return value is the receiver itself (or a copy that
110
+ # leaves the original untouched).
111
+ PURE_SELF_RETURNERS = %i[freeze dup clone itself].freeze
112
+
105
113
  module_function
106
114
 
115
+ # True when `method_name` is a pure self-returner that must
116
+ # not invalidate the receiver's facts.
117
+ def pure_self_returner?(method_name)
118
+ PURE_SELF_RETURNERS.include?(method_name)
119
+ end
120
+
107
121
  # Returns a scope with the call's receiver widened, when the
108
122
  # receiver is a local-/instance-variable read whose current
109
123
  # binding is a literal-shape carrier (`Tuple` / `HashShape`)
@@ -114,6 +128,8 @@ module Rigor
114
128
  # @param current_scope [Rigor::Scope]
115
129
  # @return [Rigor::Scope]
116
130
  def widen_after_call(call_node:, current_scope:)
131
+ return current_scope if pure_self_returner?(call_node.name)
132
+
117
133
  receiver = call_node.receiver
118
134
  return current_scope if receiver.nil?
119
135
 
@@ -181,6 +197,8 @@ module Rigor
181
197
  end
182
198
 
183
199
  def widen_for_outer_receiver(call_node, scope)
200
+ return scope if pure_self_returner?(call_node.name)
201
+
184
202
  receiver = call_node.receiver
185
203
  return scope if receiver.nil?
186
204
 
@@ -19,7 +19,7 @@ module Rigor
19
19
  # (does a diagnostic fire) is the phased mutation tier.
20
20
  class ProtectionScanner
21
21
  # A single unprotected call site.
22
- Site = Data.define(:line, :receiver, :method_name)
22
+ Site = Data.define(:line, :receiver, :method_name, :dynamic_origin)
23
23
 
24
24
  FileResult = Data.define(:protected_count, :unprotected_count, :sites) do
25
25
  def total = protected_count + unprotected_count
@@ -43,14 +43,17 @@ module Rigor
43
43
  Source::NodeWalker.each(root) do |node|
44
44
  next unless dispatch_site?(node)
45
45
 
46
- receiver_type = index[node.receiver].type_of(node.receiver)
46
+ scope = index[node.receiver]
47
+ receiver_type = scope.type_of(node.receiver)
47
48
  if concrete_receiver?(receiver_type)
48
49
  protected_count += 1
49
50
  else
51
+ origin = scope.dynamic_origins[node.receiver]
50
52
  sites << Site.new(
51
53
  line: node.location.start_line,
52
54
  receiver: safe_describe(receiver_type),
53
- method_name: node.name.to_s
55
+ method_name: node.name.to_s,
56
+ dynamic_origin: origin
54
57
  )
55
58
  end
56
59
  end
@@ -1861,13 +1861,13 @@ module Rigor
1861
1861
  end
1862
1862
 
1863
1863
  # ADR-37 slice 2 / ADR-52 WD3 — gathers each plugin's post-return
1864
- # narrowing from the method-gated `type_specifier` DSL, wrapped as
1864
+ # narrowing from the method-gated `narrowing_facts` DSL, wrapped as
1865
1865
  # a facts-only `FlowContribution`, swallowing per-plugin
1866
1866
  # exceptions so a buggy plugin can't abort the assertion path.
1867
1867
  EMPTY_CONTRIBUTIONS = [].freeze
1868
1868
  private_constant :EMPTY_CONTRIBUTIONS
1869
1869
 
1870
- # Fast-exit guard: skip if no plugin declares a `type_specifier`, or if
1870
+ # Fast-exit guard: skip if no plugin declares a `narrowing_facts` rule, or if
1871
1871
  # no registered method-name gate matches the call. See
1872
1872
  # `collect_gated_statement_contributions` for the full consultation.
1873
1873
  def collect_plugin_contributions(registry, call_node, current_scope)
@@ -1882,7 +1882,7 @@ module Rigor
1882
1882
  end
1883
1883
 
1884
1884
  # ADR-37 slice 2 / ADR-52 WD1 — post-gate walk in registry order.
1885
- # Visits only plugins in `for_statement` (declare a `type_specifier`),
1885
+ # Visits only plugins in `for_statement` (declare a `narrowing_facts` rule),
1886
1886
  # further gated by the method-name Set probe so the common no-candidate
1887
1887
  # case is a single lookup. Accumulates lazily; caller is read-only.
1888
1888
  def collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
@@ -2918,7 +2918,8 @@ module Rigor
2918
2918
  environment: scope.environment,
2919
2919
  locals: {}.freeze,
2920
2920
  source_path: scope.source_path,
2921
- discovery: scope.discovery
2921
+ discovery: scope.discovery,
2922
+ dynamic_origins: scope.dynamic_origins
2922
2923
  )
2923
2924
  end
2924
2925
 
@@ -20,7 +20,7 @@ module Rigor
20
20
  #
21
21
  # This class implements all plugin protocol hooks: per-call
22
22
  # return-type contributions (`dynamic_return`), narrowing-fact
23
- # contributions (`type_specifier`), AST node rules (`node_rule`),
23
+ # contributions (`narrowing_facts`), AST node rules (`node_rule`),
24
24
  # and producer/cache hooks. Cumulative implementation per the
25
25
  # ADR-37 / ADR-52 slice chain.
26
26
  #
@@ -440,19 +440,32 @@ module Rigor
440
440
  # `post_return_facts` slot of the deleted `flow_contribution_for`
441
441
  # hook (ADR-52 WD3):
442
442
  #
443
- # type_specifier methods: [:assert_kind_of] do |call_node, scope|
443
+ # narrowing_facts methods: [:assert_kind_of] do |call_node, scope|
444
444
  # # return an Array of post-return facts, or nil
445
445
  # end
446
446
  #
447
447
  # `methods:` is a non-empty Array of method names; the engine
448
448
  # calls the block only when `call_node.name` is one of them. The
449
449
  # block returns the same `post_return_facts` the merger applies.
450
- def type_specifier(methods:, &block)
451
- raise ArgumentError, "Plugin::Base.type_specifier requires a block body" if block.nil?
450
+ #
451
+ # This hook supplies post-return *narrowing facts* the
452
+ # predicate/assertion edges a call establishes about its
453
+ # arguments or receiver, e.g. `assert_kind_of(String, x)` ⇒ `x`
454
+ # is narrowed to `String` on the continuation. It does NOT give a
455
+ # call a *type*; for that use {dynamic_return} (per-call-site) or
456
+ # contribute RBS via the manifest `signature_paths:`.
457
+ # `dynamic_return` is the type slot, this is the fact slot.
458
+ #
459
+ # Renamed from `type_specifier` (ADR-80): the old name read as a
460
+ # parallel to `dynamic_return` (a type) when it actually returns
461
+ # facts. {.type_specifier} survives as a deprecating alias through
462
+ # 0.2.x and is removed in 0.3.0.
463
+ def narrowing_facts(methods:, &block)
464
+ raise ArgumentError, "Plugin::Base.narrowing_facts requires a block body" if block.nil?
452
465
  unless methods.is_a?(Array) && !methods.empty? &&
453
466
  methods.all? { |m| m.is_a?(Symbol) || (m.is_a?(String) && !m.empty?) }
454
467
  raise ArgumentError,
455
- "Plugin::Base.type_specifier methods: must be a non-empty Array of Symbol/String, " \
468
+ "Plugin::Base.narrowing_facts methods: must be a non-empty Array of Symbol/String, " \
456
469
  "got #{methods.inspect}"
457
470
  end
458
471
 
@@ -461,6 +474,20 @@ module Rigor
461
474
  nil
462
475
  end
463
476
 
477
+ # DEPRECATED (ADR-80) — renamed to {.narrowing_facts}. This hook
478
+ # supplies post-return narrowing *facts*, not a type; the old
479
+ # name misleads by parallel with {.dynamic_return}. Retained as a
480
+ # warning-emitting alias through 0.2.x; REMOVED in 0.3.0. Migrate
481
+ # `type_specifier methods: …` → `narrowing_facts methods: …`.
482
+ def type_specifier(methods:, &)
483
+ unless @type_specifier_deprecation_warned
484
+ @type_specifier_deprecation_warned = true
485
+ warn("[rigor] Plugin::Base.type_specifier is deprecated (ADR-80) and will be " \
486
+ "removed in Rigor 0.3.0; rename it to `narrowing_facts`. (#{name})")
487
+ end
488
+ narrowing_facts(methods:, &)
489
+ end
490
+
464
491
  # Frozen snapshot of the declared type-specifier rules. Memoised
465
492
  # for the same reason as {dynamic_returns} — consulted per plugin
466
493
  # per dispatch, over an array fixed at class-definition time.
@@ -504,7 +531,7 @@ module Rigor
504
531
  # production users migrated. Per-call return types are declared via
505
532
  # the gated {.dynamic_return} DSL (static / run-time / per-file
506
533
  # name sets, static / run-time receiver sets); post-return
507
- # narrowing facts via {.type_specifier}. See the CHANGELOG
534
+ # narrowing facts via {.narrowing_facts}. See the CHANGELOG
508
535
  # migration note for the idiom-by-idiom mapping.
509
536
 
510
537
  # ADR-9 slice 3 — per-run preparation hook. The runner
@@ -625,7 +652,7 @@ module Rigor
625
652
  end
626
653
 
627
654
  # ADR-37 slice 2 — the post-return narrowing facts contributed by
628
- # this plugin's {.type_specifier} rules for a call. The engine
655
+ # this plugin's {.narrowing_facts} rules for a call. The engine
629
656
  # calls this from `StatementEvaluator`; a rule fires only when
630
657
  # `call_node.name`
631
658
  # is one of its declared `methods:`. Failures isolate to [].
@@ -148,7 +148,7 @@ module Rigor
148
148
  "plugin #{(plugin.class.name || plugin.class).inspect} defines `flow_contribution_for`, " \
149
149
  "which was removed (ADR-52). Declare the per-call return type via `dynamic_return` " \
150
150
  "(receivers:/methods:/file_methods: gates, static or callable) and post-return narrowing " \
151
- "facts via `type_specifier` — see the CHANGELOG migration note."
151
+ "facts via `narrowing_facts` — see the CHANGELOG migration note."
152
152
  end
153
153
 
154
154
  # Same `Method#owner` trick for the per-file diagnostics hook —
data/lib/rigor/scope.rb CHANGED
@@ -23,7 +23,8 @@ module Rigor
23
23
  :ivars, :cvars, :globals,
24
24
  :indexed_narrowings, :method_chain_narrowings,
25
25
  :declaration_sourced,
26
- :source_path, :discovery, :struct_fold_safe_locals
26
+ :source_path, :discovery, :struct_fold_safe_locals,
27
+ :dynamic_origins
27
28
 
28
29
  # ADR-53 Track A — the seed-time discovery tables live on the
29
30
  # {DiscoveryIndex} the scope carries by a single reference; the
@@ -122,6 +123,11 @@ module Rigor
122
123
  end
123
124
  end
124
125
 
126
+ def record_dynamic_origin(node, cause)
127
+ @dynamic_origins[node] = cause
128
+ self
129
+ end
130
+
125
131
  def initialize(
126
132
  environment:, locals:,
127
133
  fact_store: Analysis::FactStore.empty,
@@ -134,7 +140,8 @@ module Rigor
134
140
  method_chain_narrowings: EMPTY_CHAIN_NARROWINGS,
135
141
  declaration_sourced: EMPTY_DECLARATION_SOURCED,
136
142
  source_path: nil,
137
- struct_fold_safe_locals: EMPTY_FOLD_SAFE
143
+ struct_fold_safe_locals: EMPTY_FOLD_SAFE,
144
+ dynamic_origins: {}.compare_by_identity
138
145
  )
139
146
  @environment = environment
140
147
  @locals = locals
@@ -149,6 +156,7 @@ module Rigor
149
156
  @declaration_sourced = declaration_sourced
150
157
  @source_path = source_path
151
158
  @struct_fold_safe_locals = struct_fold_safe_locals
159
+ @dynamic_origins = dynamic_origins
152
160
  freeze
153
161
  end
154
162
 
@@ -716,7 +724,8 @@ module Rigor
716
724
  method_chain_narrowings: @method_chain_narrowings,
717
725
  declaration_sourced: @declaration_sourced,
718
726
  source_path: @source_path,
719
- struct_fold_safe_locals: @struct_fold_safe_locals
727
+ struct_fold_safe_locals: @struct_fold_safe_locals,
728
+ dynamic_origins: @dynamic_origins
720
729
  )
721
730
  self.class.new(
722
731
  environment: environment, locals: locals,
@@ -727,7 +736,8 @@ module Rigor
727
736
  method_chain_narrowings: method_chain_narrowings,
728
737
  declaration_sourced: declaration_sourced,
729
738
  source_path: source_path,
730
- struct_fold_safe_locals: struct_fold_safe_locals
739
+ struct_fold_safe_locals: struct_fold_safe_locals,
740
+ dynamic_origins: dynamic_origins
731
741
  )
732
742
  end
733
743
 
@@ -764,7 +774,8 @@ module Rigor
764
774
  # flow-live (a method-local nil write / failed-guard narrowing), the
765
775
  # merge is flow-live and `possible-nil-receiver` fires as before.
766
776
  declaration_sourced: join_declaration_sourced(other),
767
- source_path: source_path
777
+ source_path: source_path,
778
+ dynamic_origins: @dynamic_origins
768
779
  )
769
780
  end
770
781
 
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.2.4"
4
+ VERSION = "0.2.6"
5
5
  end
data/lib/rigor.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "rigor/environment"
10
10
  require_relative "rigor/rbs_extended"
11
11
  require_relative "rigor/testing"
12
12
  require_relative "rigor/inference/budget_trace"
13
+ require_relative "rigor/inference/dynamic_origin"
13
14
  require_relative "rigor/inference/fallback"
14
15
  require_relative "rigor/inference/fallback_tracer"
15
16
  require_relative "rigor/inference/acceptance"
@@ -145,7 +145,7 @@ module Rigor
145
145
  private_constant :SPEC_MATCHER_FORM
146
146
 
147
147
  # ADR-37 slice 2 — the method names this analyzer narrows on,
148
- # for the plugin's `type_specifier methods:` gate.
148
+ # for the plugin's `narrowing_facts methods:` gate.
149
149
  SUPPORTED_METHODS = (ASSERT_FORM.keys + SPEC_MATCHER_FORM.keys).freeze
150
150
 
151
151
  def spec_form_fact(call_node, environment:)
@@ -78,7 +78,7 @@ module Rigor
78
78
  # assertion, method-gated by the engine. The engine routes
79
79
  # `:local`-kind facts through
80
80
  # `StatementEvaluator#apply_local_post_return_fact`.
81
- type_specifier methods: AssertionAnalyzer::SUPPORTED_METHODS do |call_node, scope|
81
+ narrowing_facts methods: AssertionAnalyzer::SUPPORTED_METHODS do |call_node, scope|
82
82
  AssertionAnalyzer.contribution_for(call_node, environment: scope&.environment)&.post_return_facts
83
83
  end
84
84
  end
@@ -42,6 +42,32 @@ module Rigor
42
42
  # `_controller.rb` (e.g. `users`, `admin/users`).
43
43
  CONTROLLER_PATH_RE = %r{(?:^|/)controllers/(.+)_controller\.rb$}
44
44
 
45
+ # Matches Rails view-template file paths to derive the
46
+ # I18n "virtual path" — the scope that Rails uses for
47
+ # lazy `t('.key')` lookups inside a template.
48
+ #
49
+ # Captures the path segment between `views/` and the
50
+ # format+variant+handler suffix, then strips a leading
51
+ # underscore from each segment — Rails templates resolve
52
+ # `_form.html.erb` as "form", not "_form".
53
+ #
54
+ # app/views/setting/index.html.erb → setting.index
55
+ # app/views/admin/users/new.html.erb → admin.users.new
56
+ # app/views/home/index.html+mobile.erb → home.index
57
+ # app/views/users/_form.html.erb → users.form
58
+ #
59
+ # The view-scope lazy expansion replaces the action
60
+ # part with the view's virtual path:
61
+ # `<%= t('.title') %>` → `setting.index.title`
62
+ VIEW_SCOPE_RE = %r{views/(.+?)\.(?:\w+)(?:\+\w+)?\.\w+\z}
63
+
64
+ # Regex to extract lazy-key arguments from ERB / HTML
65
+ # template content. Matches `t('.key')`, `t(".key")`,
66
+ # `I18n.t('.key')`, and `I18n.translate('.key')` with a
67
+ # leading dot on the key string. Captures only the key
68
+ # part after the dot.
69
+ LAZY_T_KEY_RE = /\b(?:I18n\.)?(?:t|translate)\s*\(\s*(?:"\.([^"\\]*)"|'\.([^'\\]*)')/
70
+
45
71
  # Reserved option keys — these are recognised by I18n
46
72
  # itself and not treated as interpolation variables.
47
73
  RESERVED_OPTION_KEYS = %i[
@@ -146,6 +172,32 @@ module Rigor
146
172
  m[1].tr("/", ".")
147
173
  end
148
174
 
175
+ def view_scope_from_path(path)
176
+ m = VIEW_SCOPE_RE.match(path.to_s)
177
+ return nil unless m
178
+
179
+ m[1].split("/").map { |seg| seg.sub(/\A_/, "") }.join(".")
180
+ end
181
+
182
+ def extract_lazy_keys_from_erb(content)
183
+ content.scan(LAZY_T_KEY_RE).map { |dq, sq| dq || sq }.uniq
184
+ end
185
+
186
+ def validate_view_key(key, locale_index:, configured_locales:)
187
+ entry = locale_index.find(key)
188
+ if entry.nil?
189
+ return [] if locale_index.pluralization_namespace?(key)
190
+ return [] if rails_shipped_key?(key)
191
+
192
+ return [unknown_key_violation(key, locale_index)]
193
+ end
194
+
195
+ violations = [translation_call_info(key, entry)]
196
+ missing = locale_index.missing_locales_for(key, configured_locales: configured_locales)
197
+ violations << missing_locale_violation(key, missing) unless missing.empty?
198
+ violations
199
+ end
200
+
149
201
  # Expands a lazy key (starting with `.`) to its full
150
202
  # dotted path using the controller scope and action name.
151
203
  # Returns the raw key unchanged for absolute keys.