rigortype 0.3.7 → 0.3.8

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.
@@ -6,39 +6,28 @@ require_relative "hkt_body"
6
6
  module Rigor
7
7
  module Inference
8
8
  class HktSugarTranslator
9
- class << self
10
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
11
- def translate(rbs_type, uri:, params_set:, name_scope:)
12
- new(uri: uri, params_set: params_set, name_scope: name_scope).translate(rbs_type)
13
- end
14
- end
15
-
16
9
  attr_reader :recursive
17
10
 
18
- def initialize(uri:, params_set:, name_scope:)
11
+ def initialize(uri:, params_set:)
19
12
  @uri = uri
20
13
  @params_set = params_set
21
- @name_scope = name_scope
22
14
  @recursive = false
23
15
  end
24
16
 
17
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
25
18
  def translate(type)
26
19
  case type
27
20
  when RBS::Types::Alias
28
- if type.name.to_s.sub(/\A::/, "") == @uri.to_s
21
+ if type.name.to_s.sub(/\A::/, "") == @uri.to_s && !type.args.empty?
29
22
  @recursive = true
30
23
  args = type.args.map { |a| translate(a) }
31
24
  HktBody::AppRef.new(uri: @uri, args: args)
32
25
  else
33
- # Re-wrap other aliases as NominalApp or TypeLeaf?
34
- # Actually, we can just translate them to TypeLeaf if they have no args,
35
- # but Rigor handles aliases via RbsLoader.
36
- # If we don't expand them here, they will just be RbsTypeTranslator.translate'd later?
37
- # Wait, HktBody doesn't have an AliasRef node. HktBody uses NominalApp for things with args,
38
- # or TypeLeaf for concrete Rigor::Type.
39
-
40
- # Since Rigor doesn't support generic aliases outside of HKT AppRef,
41
- # we can just translate to TypeLeaf.
26
+ # Everything else degrades to a concrete leaf: HktBody has no AliasRef node and Rigor
27
+ # resolves non-HKT aliases through RbsLoader. A bare self-reference with no type
28
+ # arguments (`type Foo::x[T] = ... | Foo::x`) lands here too — it is malformed for a
29
+ # parameterized alias (`rbs validate` rejects it), so it must not build an empty-args
30
+ # `AppRef`, which `HktBody` rejects.
42
31
  fallback_to_type_leaf(type)
43
32
  end
44
33
  when RBS::Types::ClassInstance
@@ -72,17 +61,22 @@ module Rigor
72
61
  fallback_to_type_leaf(type)
73
62
  end
74
63
  end
75
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
76
64
 
77
65
  private
78
66
 
67
+ # The `else` arm and the unbound-variable / non-recursive-alias arms all degrade a subterm the
68
+ # HKT body grammar has no node for to a concrete `Rigor::Type` leaf. `RbsTypeTranslator` has no
69
+ # name-scope parameter — it never resolves relative names — and the alias decls this walk reads
70
+ # come off a `resolve_type_names`-d environment, so names arrive already absolute and there is
71
+ # nothing a scope would do here. `alias_expander: nil` is deliberate too: a nested alias reached
72
+ # in this fallback degrades to `Dynamic[Top]` rather than pulling a second alias body into an
73
+ # HKT definition.
79
74
  def fallback_to_type_leaf(type)
80
75
  require_relative "rbs_type_translator" unless defined?(RbsTypeTranslator)
81
76
  rigor_type = RbsTypeTranslator.translate(
82
77
  type,
83
78
  alias_expander: nil,
84
79
  type_vars: {},
85
- name_scope: @name_scope,
86
80
  self_type: Rigor::Type::Combinator.untyped,
87
81
  instance_type: Rigor::Type::Combinator.untyped
88
82
  )
@@ -316,9 +316,25 @@ module Rigor
316
316
  # determined by the user's `plugins:` list); user `.rbs` overlays merge on top of this overlay last.
317
317
  # Returns `Inference::HktRegistry::EMPTY` when no plugin contributes HKT entries so callers can skip the
318
318
  # merge.
319
+ #
320
+ # Issue #791 — a plugin's contribution is read inside a per-plugin rescue that re-raises the SAME
321
+ # exception class with the plugin named, and with the original backtrace, so the
322
+ # `rbs.coverage.hkt-scan-failed` row this ends up on says WHICH plugin to remove instead of only that
323
+ # "the plugin overlay" failed. `Exception#exception(message)` clones rather than re-constructing, so
324
+ # an exception class with a non-standard `initialize` survives the re-raise; `cause` is set to the
325
+ # original automatically. The aggregation itself (a duplicate URI across two plugins, say) is not
326
+ # attributable to one plugin and raises unnamed — the stage still is, which is what the row needs.
319
327
  def hkt_overlay_registry
320
- registrations = plugins.flat_map { |plugin| plugin.manifest.hkt_registrations }
321
- definitions = plugins.flat_map { |plugin| plugin.manifest.hkt_definitions }
328
+ registrations = []
329
+ definitions = []
330
+ plugins.each do |plugin|
331
+ manifest = plugin.manifest
332
+ registrations.concat(manifest.hkt_registrations)
333
+ definitions.concat(manifest.hkt_definitions)
334
+ rescue StandardError => e
335
+ raise e, "plugin #{safe_plugin_id(plugin).inspect} raised while contributing HKT " \
336
+ "registrations: #{e.message}", e.backtrace
337
+ end
322
338
  return Inference::HktRegistry::EMPTY if registrations.empty? && definitions.empty?
323
339
 
324
340
  Inference::HktRegistry.new(registrations: registrations, definitions: definitions)
@@ -455,6 +471,13 @@ module Rigor
455
471
  rescue StandardError
456
472
  []
457
473
  end
474
+
475
+ # Issue #791 — the id to name in the HKT-overlay failure message, derived through {#safe_manifest}
476
+ # because a raising manifest read is one of the shapes that gets here. Mirrors `Runner#safe_plugin_id`
477
+ # (the `:plugin_loader` envelope's own naming) so both failure surfaces name a plugin the same way.
478
+ def safe_plugin_id(plugin)
479
+ safe_manifest(plugin)&.id || plugin.class.to_s
480
+ end
458
481
  end
459
482
 
460
483
  # Assigned after the class body completes — `Registry.new` runs at assignment time and `#initialize` calls
@@ -22,14 +22,15 @@ module Rigor
22
22
  #
23
23
  # ## Why it parses rather than reading the built environment
24
24
  #
25
- # {ConformanceChecker}, the obvious model, walks `RbsLoader`'s built env. An envelope cannot: the
25
+ # {ConformanceChecker}, the obvious model, walks `RbsLoader`'s built env. An envelope did not: the
26
26
  # diagnostic has to name **where the bound was written** (`sig/foo.rbs:12`), and the ADR-54 env
27
- # cache dumps every `RBS::Location` to a zero-range `<cached>` sentinel, so a warm run would lose
28
- # both the position and the only evidence of which file a declaration came from. Parsing the
29
- # project's own signature sources is a few milliseconds over a tree Rigor already globs, it is
30
- # identical warm and cold, and it enforces ADR-103 WD6's trust rule structurally: a `%a{pure}` in
31
- # rbs core or in a gem's shipped RBS is never read, so it cannot bound a project method that
32
- # happens to share its key.
27
+ # cache reduced every `RBS::Location` to a zero-range `<cached>` sentinel, so a warm run lost both
28
+ # the position and the only evidence of which file a declaration came from. That particular loss is
29
+ # closed now #696 restored the buffer name, #799 an annotation's position so what still decides
30
+ # it is the rest: parsing the project's own signature sources is a few milliseconds over a tree
31
+ # Rigor already globs, it is identical warm and cold, and it enforces ADR-103 WD6's trust rule
32
+ # structurally: a `%a{pure}` in rbs core or in a gem's shipped RBS is never read, so it cannot bound
33
+ # a project method that happens to share its key.
33
34
  #
34
35
  # Malformed payloads and `pure`-versus-`effect` contradictions are recorded on a {Reporter} the
35
36
  # scanner owns and ride out on {Result#unresolved}. They surface no diagnostic in this slice:
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../inference/hkt_registry"
4
4
  require_relative "../inference/hkt_body_parser"
5
+ require_relative "reporter"
5
6
 
6
7
  module Rigor
7
8
  module RbsExtended
@@ -286,10 +287,24 @@ module Rigor
286
287
  source_location.respond_to?(:start_line) ? source_location.start_line : nil
287
288
  end
288
289
 
290
+ # Issue #785 — routes a declined directive to the per-run reporter. The FIRST arm is the one that
291
+ # matters: the production reporter is {RbsExtended::Reporter}, whose surface is `record_unresolved` /
292
+ # `record_lossy_projection` / `record_hkt_error` — it has neither `#record` nor `#<<`, so before this
293
+ # arm existed every call here fell off the end of the `if` and every malformed HKT directive was
294
+ # dropped in every real run, while the doc comments claimed an `:info` entry was recorded. The
295
+ # position is flattened at record time through {RbsExtended::Reporter.position_of}, the one place
296
+ # every stream flattens through since #805, because the entry has to reach the pool drain channel as
297
+ # primitives (see {RbsExtended::Reporter}).
298
+ #
299
+ # The `#record` / `#<<` arms stay for the collecting doubles the directive specs and plugin authors
300
+ # use, which is the contract `scan_rbs_loader`'s `@param reporter [#record, nil]` documents.
289
301
  def record_hkt_error(reporter, message, source_location)
290
302
  return if reporter.nil?
291
303
 
292
- if reporter.respond_to?(:record)
304
+ if reporter.respond_to?(:record_hkt_error)
305
+ path, line, column = Reporter.position_of(source_location)
306
+ reporter.record_hkt_error(message: message, path: path, line: line, column: column)
307
+ elsif reporter.respond_to?(:record)
293
308
  reporter.record(directive: "hkt", message: message, source_location: source_location)
294
309
  elsif reporter.respond_to?(:<<)
295
310
  reporter << { directive: "hkt", message: message, source_location: source_location }
@@ -6,7 +6,7 @@ module Rigor
6
6
  # cannot surface at the point of failure (the parsers are fail-soft, returning `nil` so call sites fall back
7
7
  # to the RBS-declared type).
8
8
  #
9
- # Owns two event streams:
9
+ # Owns three event streams:
10
10
  #
11
11
  # - `#unresolved_payloads` — `rigor:v1:*` directive payloads the resolver could not turn into a
12
12
  # {Rigor::Type}. Surface as `dynamic.rbs-extended.unresolved` `:info` diagnostics.
@@ -14,21 +14,60 @@ module Rigor
14
14
  # `required_of` / `readonly_of`) applied to a carrier that does not preserve shape information (anything
15
15
  # other than `Type::HashShape` / `Type::Tuple`). Surface as `dynamic.shape.lossy-projection` `:info`
16
16
  # diagnostics.
17
+ # - `#hkt_directive_errors` — malformed `rigor:v1:hkt_register` / `rigor:v1:hkt_define` directives the
18
+ # ADR-20 parser declined. Surface as `dynamic.rbs-extended.hkt-directive-invalid` `:info` diagnostics.
17
19
  #
18
20
  # Mutable through the run; consumed once by {Rigor::Analysis::Runner} at end-of-run. Each event is
19
- # deduplicated by `(payload, source_location)` for unresolved and `(head, source_location)` for
20
- # lossy-projection so a single annotation read from many call sites yields one diagnostic.
21
+ # deduplicated by its whole entry — `(payload, path, line, column)` for unresolved, `(head, path, line,
22
+ # column)` for lossy-projection, `(message, path, line, column)` for an hkt directive so a single
23
+ # annotation read from many call sites yields one diagnostic.
21
24
  #
22
25
  # The reporter is intentionally thread-safe via a coarse `Mutex` because the inference engine may read the
23
26
  # same method definition from multiple files in parallel; the critical sections are short (Array#include? +
24
27
  # Array#<<) so the lock contention is negligible.
25
28
  class Reporter
26
- UnresolvedEntry = Data.define(:payload, :source_location)
27
- LossyProjectionEntry = Data.define(:head, :source_location)
29
+ # Every entry carries its position as `(path, line, column)` primitives, flattened from the parser's
30
+ # `RBS::Location` by {.position_of} at record time. Issues #785 (hkt) and #805 (the two older streams).
31
+ # Two reasons, and either alone decides it. All three streams are drained out of every fork-pool worker
32
+ # ({Rigor::Analysis::WorkerSession#drain_reporters}), and an `RBS::Location` is a C-extension object with
33
+ # no `_dump`, so a `Marshal.dump` of the drain payload raises `TypeError` — which kills the worker at
34
+ # drain time and degrades the run to in-process re-analysis. And an `RBS::Location` compares equal only
35
+ # against a location over the SAME `RBS::Buffer` object, so two workers that each read the same `.rbs`
36
+ # would hand the coordinator two entries the dedup cannot collapse — `--workers=N` would then print N
37
+ # copies of a row `--workers=0` prints once.
38
+ #
39
+ # The record methods take the triple rather than the location for the same reason: a door that accepts
40
+ # an `RBS::Location` is a door a future caller reintroduces the bug through.
41
+ UnresolvedEntry = Data.define(:payload, :path, :line, :column)
42
+ LossyProjectionEntry = Data.define(:head, :path, :line, :column)
43
+ HktDirectiveEntry = Data.define(:message, :path, :line, :column)
44
+
45
+ # Flattens an `RBS::Location` (or anything answering the same readers) to the `(path, line, column)`
46
+ # triple every entry carries. `column` is 1-based, since `RBS::Location#start_column` is 0-based and
47
+ # diagnostics are not. Each component is `nil` when the location cannot supply it, and the diagnostic
48
+ # then falls back to `.rigor.yml:1:1`.
49
+ #
50
+ # Fail-soft by construction, like every parser that calls it: a location that raises while being read
51
+ # costs the entry its position, never the run.
52
+ #
53
+ # @param source_location [RBS::Location, nil]
54
+ # @return [Array(String, nil, Integer, nil, Integer, nil)]
55
+ def self.position_of(source_location)
56
+ return [nil, nil, nil] if source_location.nil?
57
+
58
+ buffer = source_location.respond_to?(:buffer) ? source_location.buffer : nil
59
+ name = buffer.respond_to?(:name) ? buffer.name.to_s : ""
60
+ line = source_location.respond_to?(:start_line) ? source_location.start_line : nil
61
+ column = source_location.respond_to?(:start_column) ? source_location.start_column + 1 : nil
62
+ [name.empty? ? nil : name, line, column]
63
+ rescue StandardError
64
+ [nil, nil, nil]
65
+ end
28
66
 
29
67
  def initialize
30
68
  @unresolved_payloads = []
31
69
  @lossy_projections = []
70
+ @hkt_directive_errors = []
32
71
  @mutex = Mutex.new
33
72
  end
34
73
 
@@ -42,11 +81,13 @@ module Rigor
42
81
  @mutex.synchronize { @lossy_projections.dup.freeze }
43
82
  end
44
83
 
45
- # Records a `dynamic.rbs-extended.unresolved` event. The `source_location` argument is the {RBS::Location}
46
- # attached to the source annotation (or `nil` when the caller doesn't have one the diagnostic falls back
47
- # to a generic location in that case).
48
- def record_unresolved(payload:, source_location: nil)
49
- entry = UnresolvedEntry.new(payload: payload.to_s, source_location: source_location)
84
+ # Records a `dynamic.rbs-extended.unresolved` event. The position triple is the source annotation's
85
+ # `.rbs` file / line / 1-based column {.position_of} flattens the caller's `RBS::Location` into it
86
+ # each `nil` when the caller had no location (the diagnostic then falls back to `.rigor.yml:1:1`).
87
+ def record_unresolved(payload:, path: nil, line: nil, column: nil)
88
+ entry = UnresolvedEntry.new(
89
+ payload: frozen_text(payload), path: frozen_text(path), line: line, column: column
90
+ )
50
91
  @mutex.synchronize do
51
92
  return if @unresolved_payloads.include?(entry)
52
93
 
@@ -56,8 +97,11 @@ module Rigor
56
97
 
57
98
  # Records a `dynamic.shape.lossy-projection` event for one of the five shape-projection heads. `head` MUST
58
99
  # be a String (`"pick_of"`, `"omit_of"`, …); the diagnostic message identifies which projection degraded.
59
- def record_lossy_projection(head:, source_location: nil)
60
- entry = LossyProjectionEntry.new(head: head.to_s, source_location: source_location)
100
+ # The position triple is read exactly as {#record_unresolved}'s is.
101
+ def record_lossy_projection(head:, path: nil, line: nil, column: nil)
102
+ entry = LossyProjectionEntry.new(
103
+ head: frozen_text(head), path: frozen_text(path), line: line, column: column
104
+ )
61
105
  @mutex.synchronize do
62
106
  return if @lossy_projections.include?(entry)
63
107
 
@@ -65,10 +109,46 @@ module Rigor
65
109
  end
66
110
  end
67
111
 
112
+ # @return [Array<HktDirectiveEntry>] frozen snapshot of the accumulated hkt-directive failures.
113
+ def hkt_directive_errors
114
+ @mutex.synchronize { @hkt_directive_errors.dup.freeze }
115
+ end
116
+
117
+ # Records a `dynamic.rbs-extended.hkt-directive-invalid` event: one malformed ADR-20 HKT directive the
118
+ # {Rigor::RbsExtended::HktDirectives} parser declined. `message` names what the parser objected to; the
119
+ # position triple is the annotation's `.rbs` file / line / 1-based column, each `nil` when the caller had
120
+ # no location (the diagnostic then falls back to `.rigor.yml:1:1`).
121
+ #
122
+ # Every String is frozen individually, not just the enclosing `Data`: the entry crosses the pool drain
123
+ # channel, whose stated invariant is that its payload is `Ractor.shareable?` as well as Marshal-clean.
124
+ def record_hkt_error(message:, path: nil, line: nil, column: nil)
125
+ entry = HktDirectiveEntry.new(
126
+ message: frozen_text(message), path: frozen_text(path), line: line, column: column
127
+ )
128
+ @mutex.synchronize do
129
+ return if @hkt_directive_errors.include?(entry)
130
+
131
+ @hkt_directive_errors << entry
132
+ end
133
+ end
134
+
68
135
  # True when no events have accumulated. Used by callers that want to skip the diagnostic-emission pass
69
136
  # entirely on the common no-event path.
70
137
  def empty?
71
- @mutex.synchronize { @unresolved_payloads.empty? && @lossy_projections.empty? }
138
+ @mutex.synchronize do
139
+ @unresolved_payloads.empty? && @lossy_projections.empty? && @hkt_directive_errors.empty?
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ # `nil` stays `nil` — the diagnostic falls back to `.rigor.yml:1:1` on a missing path. Anything else
146
+ # becomes an individually frozen String: the `Data` wrapper is frozen on its own, but the pool drain's
147
+ # stated invariant is that its payload is `Ractor.shareable?`, which is a DEEP freeze. Coercing here also
148
+ # means a caller that hands the door an `RBS::Location` by mistake stores its `#to_s`, not the object —
149
+ # the entry cannot become un-Marshalable from the outside.
150
+ def frozen_text(value)
151
+ value.nil? ? nil : value.to_s.dup.freeze
72
152
  end
73
153
  end
74
154
  end
@@ -812,10 +812,15 @@ module Rigor
812
812
  # ADR-13 slice 3b — guards every reporter call so the in-RbsExtended-module call sites can record events
813
813
  # uniformly without nil-checking each time. When the reporter is nil (the v0.1.0 → v0.1.3 default for call
814
814
  # sites that do not yet thread `environment:`), the call is a no-op and the parser stays fail-soft.
815
+ #
816
+ # Issue #805 — the location is flattened to `(path, line, column)` here, at record time, because the
817
+ # entry is drained out of every fork-pool worker and an `RBS::Location` neither survives `Marshal.dump`
818
+ # nor compares equal across two workers' buffers (see {Rigor::RbsExtended::Reporter}).
815
819
  def record_unresolved(reporter, payload, source_location)
816
820
  return if reporter.nil?
817
821
 
818
- reporter.record_unresolved(payload: payload, source_location: source_location)
822
+ path, line, column = Reporter.position_of(source_location)
823
+ reporter.record_unresolved(payload: payload, path: path, line: line, column: column)
819
824
  end
820
825
  end
821
826
  end
@@ -24,12 +24,16 @@ module Rigor
24
24
  # body's last expression to derive an inferred return, looks up the project's existing RBS declaration (if
25
25
  # any), and emits one {MethodCandidate} per def.
26
26
  #
27
- # The MVP keeps the scope deliberately narrow:
28
- # - Only instance methods inside a `class` / `module` body are considered. Top-level / DSL-block / singleton
29
- # defs are skipped (`sig.skipped.complex-shape`).
30
- # - Parameter signatures are hard-coded to `untyped` per ADR-14 § "Robustness principle compliance" clause 2;
31
- # `--params=observed` arrives in slice 3.
32
- # - Optional / rest / keyword / block params disqualify the def (`sig.skipped.complex-shape`).
27
+ # The scope, as it stands after the slices that widened the MVP:
28
+ # - Instance and singleton methods inside a nameable `class` / `module` body are considered. Top-level /
29
+ # DSL-block defs have no nameable receiver and are not candidates.
30
+ # - Parameter types are `untyped` per ADR-14 § "Robustness principle compliance" clause 2 unless
31
+ # `--params=observed` supplies call-site observations; the parameter LIST always mirrors the def's runtime
32
+ # shape (required / optional / rest / trailing / keyword / keyword-rest / forwarding / block). Issue #778
33
+ # retired the slice-1 gate that skipped every shape beyond required positionals as
34
+ # `sig.skipped.complex-shape`: the body typer binds every parameter to `untyped` when no RBS declares the
35
+ # method, so the inferred return is the same clause-1 answer for every shape, and the renderer the
36
+ # `initialize` stub already used spells them all.
33
37
  # - A `Dynamic[top]` inferred return becomes `sig.skipped.untyped-return` — emitting `untyped` would obscure
34
38
  # rather than help.
35
39
  # - Tighter-return detection compares the RBS-erased spellings only when the existing declared return
@@ -650,8 +654,8 @@ module Rigor
650
654
  end
651
655
 
652
656
  # Emits `def initialize: (<shape>) -> void`. The return is always `void` because Ruby's `initialize`
653
- # return value is never meaningful. The parameter list mirrors the runtime shape (required / optional /
654
- # rest / keyword / keyword-rest / block).
657
+ # return value is never meaningful. The parameter list mirrors the runtime shape through the same
658
+ # {#render_param_list} every other def renders with.
655
659
  #
656
660
  # When `--params=observed` populates `@observations` for `[class_name, :initialize]` (via the
657
661
  # `ObservationCollector`'s `.new` → `:initialize` routing), positional and keyword arg types come from the
@@ -659,7 +663,7 @@ module Rigor
659
663
  # clause 2.
660
664
  def initialize_stub_candidate(path, def_node, class_name)
661
665
  params = def_node.parameters
662
- rbs = "def initialize: (#{render_initialize_param_list(params, class_name)})" \
666
+ rbs = "def initialize: (#{render_param_list(params, class_name, :initialize)})" \
663
667
  "#{block_signature_suffix(params)} -> void"
664
668
  build_candidate(
665
669
  path: path, class_name: class_name, method_name: :initialize,
@@ -668,57 +672,81 @@ module Rigor
668
672
  )
669
673
  end
670
674
 
671
- def render_initialize_param_list(params, class_name)
675
+ # The RBS parameter list of a def, mirroring its runtime shape position by position: `untyped` per
676
+ # required positional, `?untyped` per optional, `*untyped` for a rest, one `untyped` per trailing
677
+ # positional after the rest, `name: untyped` / `?name: untyped` per keyword, `**untyped` for a keyword
678
+ # rest, and `*untyped, **untyped` for `...` forwarding (its block half is {#block_signature_suffix}'s).
679
+ # `**nil` declares that no keywords are accepted, which RBS cannot spell, so it renders nothing.
680
+ #
681
+ # Per ADR-5 clause 2 every position is `untyped` unless `--params=observed` supplied call-site
682
+ # observations, in which case each leading positional and each keyword carries the union of the types the
683
+ # arity-compatible observations passed there ({#arity_matched_observations}). The rest, the trailing
684
+ # positionals and the keyword rest stay `untyped`: an observation does not say which of its arguments the
685
+ # splat absorbed. Before #778 only `initialize` rendered through this path and every other def with a
686
+ # shape beyond required positionals was skipped as `sig.skipped.complex-shape`.
687
+ def render_param_list(params, class_name, method_name)
672
688
  return "" unless params.is_a?(Prism::ParametersNode)
673
689
 
674
- observations = initialize_observations(class_name, params)
675
- offset = 0
690
+ observations = arity_matched_observations(class_name, method_name, params)
676
691
  parts = []
677
-
678
- params.requireds.each_with_index do |_, i|
679
- parts << initialize_positional_type(observations, offset + i, "")
680
- end
681
- offset += params.requireds.size
682
-
683
- params.optionals.each_with_index do |_, i|
684
- parts << initialize_positional_type(observations, offset + i, "?")
685
- end
686
-
692
+ params.requireds.each_index { |i| parts << positional_type(observations, i, "") }
693
+ offset = params.requireds.size
694
+ params.optionals.each_index { |i| parts << positional_type(observations, offset + i, "?") }
687
695
  parts << "*untyped" if params.rest
696
+ parts.concat(Array.new(params.posts.size, "untyped"))
688
697
  params.keywords.each { |kw| parts << render_keyword_param(kw, observations) }
689
- parts << "**untyped" if params.keyword_rest
698
+ parts.concat(keyword_rest_parts(params))
690
699
  parts.join(", ")
691
700
  end
692
701
 
702
+ def keyword_rest_parts(params)
703
+ case params.keyword_rest
704
+ when Prism::ForwardingParameterNode
705
+ # `...` forwards positionals as well as keywords; a rest already rendered covers the former.
706
+ params.rest ? ["**untyped"] : ["*untyped", "**untyped"]
707
+ when Prism::KeywordRestParameterNode then ["**untyped"]
708
+ else []
709
+ end
710
+ end
711
+
693
712
  # The RBS block suffix for a `def` that takes a `&block` parameter, e.g. ` ?{ (*untyped) -> untyped }`.
694
713
  # A block belongs AFTER the parameter parens in RBS (`(params) ?{ block } -> ret`), not inside them —
695
714
  # emitting it as a comma-joined member produced `(**untyped, ?{ (?) -> void })`, which RBS rejects
696
715
  # (`optional keyword argument type is expected`) and which then collapsed the whole env build. sig-gen
697
716
  # never observes the block's own signature, so it is rendered maximally lenient (ADR-5): an optional
698
- # block (`?{`, since a `&block` need not be passed) taking `*untyped` and returning `untyped`. Returns
699
- # "" when the method takes no block.
717
+ # block (`?{`, since a `&block` need not be passed) taking `*untyped` and returning `untyped`. `...`
718
+ # forwards a block too and gets the same suffix. Returns "" when the method takes no block.
700
719
  def block_signature_suffix(params)
701
720
  return "" unless params.is_a?(Prism::ParametersNode)
702
- return "" if params.block.nil?
721
+ return "" unless params.block || params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
703
722
 
704
723
  " ?{ (*untyped) -> untyped }"
705
724
  end
706
725
 
707
- # Picks observations under `[class_name, :initialize]` whose positional arity matches the def's accepted
708
- # range (required..required+optional). Looser arities don't get used because they describe a different
709
- # overload the stub cannot express.
710
- def initialize_observations(class_name, params)
726
+ # Observations under `[class_name, method_name]` whose positional arity the def accepts: at least its
727
+ # required count (leading plus trailing), and, unless a rest or `...` absorbs the surplus, at most
728
+ # required plus optional. An arity outside that window describes a different overload the signature
729
+ # cannot express, so it says nothing about these positions. #778 widened the window for ordinary defs
730
+ # from the exact required count — `def optional(text = "x")` called as `optional` AND as `optional("y")`
731
+ # now credits both call sites.
732
+ def arity_matched_observations(class_name, method_name, params)
711
733
  return [] if @observations.empty?
712
734
 
713
- list = @observations[[class_name, :initialize]] || []
714
- min = params.requireds.size
715
- max = min + params.optionals.size
716
- list.select { |obs| (min..max).cover?(obs.positional.size) }
735
+ list = @observations[[class_name, method_name]] || []
736
+ min = params.requireds.size + params.posts.size
737
+ max = unbounded_positionals?(params) ? Float::INFINITY : min + params.optionals.size
738
+ list.select { |obs| obs.positional.size.between?(min, max) }
739
+ end
740
+
741
+ def unbounded_positionals?(params)
742
+ !params.rest.nil? || params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
717
743
  end
718
744
 
719
- def initialize_positional_type(observations, index, prefix)
745
+ # A bare union is a valid positional type in RBS (`(String | Integer, ?String | Integer)`), so unlike the
746
+ # return position it is not parenthesised.
747
+ def positional_type(observations, index, prefix)
720
748
  types = observations.filter_map { |obs| obs.positional[index] }
721
- "#{prefix}#{types.empty? ? 'untyped' : paren_wrap_union(union_erase(types))}"
749
+ "#{prefix}#{union_erase(types)}"
722
750
  end
723
751
 
724
752
  def render_keyword_param(keyword, observations)
@@ -746,10 +774,6 @@ module Rigor
746
774
  return nil if initialize_excludes?(def_node, kind)
747
775
  return initialize_stub_candidate(path, def_node, class_name) if non_trivial_initialize?(def_node, kind)
748
776
 
749
- unless simple_parameter_shape?(def_node.parameters)
750
- return skipped(path, def_node, class_name, kind, :complex_shape)
751
- end
752
-
753
777
  inferred = infer_return_type(def_node, scope_index)
754
778
  return skipped(path, def_node, class_name, kind, :untyped_return) if inferred.nil? || dynamic_top?(inferred)
755
779
 
@@ -763,20 +787,6 @@ module Rigor
763
787
  end
764
788
  end
765
789
 
766
- # Required positionals only; the MVP's body-typing path gives well-defined returns for that shape.
767
- # Optional / rest / keyword / block parameters route through the `sig.skipped.complex-shape` reason until
768
- # slices 3+ widen the param policy.
769
- def simple_parameter_shape?(params)
770
- return true if params.nil?
771
- return false unless params.is_a?(Prism::ParametersNode)
772
-
773
- params.optionals.empty? &&
774
- params.rest.nil? &&
775
- params.keywords.empty? &&
776
- params.keyword_rest.nil? &&
777
- params.block.nil?
778
- end
779
-
780
790
  # Mirrors the `def.return-type-mismatch` rule's body-type extraction: type the implicit-return expression
781
791
  # under the scope the indexer associated with the body. The parameter bindings (typed `untyped` per the
782
792
  # indexer's default) come from `with_local` inside `StatementEvaluator`; the result is the carrier the
@@ -991,8 +1001,8 @@ module Rigor
991
1001
  end
992
1002
 
993
1003
  def render_rbs_line(def_node, inferred, class_name, kind)
994
- arity = required_arity(def_node)
995
- head = arity.zero? ? "()" : "(#{render_param_list(class_name, def_node.name, arity)})"
1004
+ params = def_node.parameters
1005
+ head = "(#{render_param_list(params, class_name, def_node.name)})#{block_signature_suffix(params)}"
996
1006
  prefix = method_def_prefix(class_name, def_node.name, kind)
997
1007
  "#{prefix}#{def_node.name}: #{head} -> #{paren_wrap_union(elaborated_rbs(inferred))}"
998
1008
  end
@@ -1028,30 +1038,6 @@ module Rigor
1028
1038
  false
1029
1039
  end
1030
1040
 
1031
- def required_arity(def_node)
1032
- params = def_node.parameters
1033
- params.is_a?(Prism::ParametersNode) ? params.requireds.size : 0
1034
- end
1035
-
1036
- # Per ADR-5 clause 2 the default is `untyped` for every position. Observed-policy callers
1037
- # (`--params=observed`) pass an `observations:` map at construction time; the generator unions
1038
- # per-position arg types whose tuple arity matches the def's required-positional count. Observations from
1039
- # arities other than the def's count are discarded — they describe a different overload the MVP does not
1040
- # emit.
1041
- def render_param_list(class_name, method_name, arity)
1042
- tuples = matching_observations(class_name, method_name, arity)
1043
- return Array.new(arity, "untyped").join(", ") if tuples.empty?
1044
-
1045
- Array.new(arity) { |i| union_erase(tuples.map { |obs| obs.positional[i] }) }.join(", ")
1046
- end
1047
-
1048
- def matching_observations(class_name, method_name, arity)
1049
- return [] if @observations.empty?
1050
-
1051
- list = @observations[[class_name, method_name]] || []
1052
- list.select { |obs| obs.positional.size == arity }
1053
- end
1054
-
1055
1041
  def union_erase(types)
1056
1042
  return "untyped" if types.empty?
1057
1043
  return elaborated_rbs(types.first) if types.size == 1
@@ -14,7 +14,8 @@ module Rigor
14
14
  # - `:diff` — a unified-style diff comparing the existing RBS spelling (if any) against the inferred
15
15
  # spelling. The MVP renders a minimal "- declared / + inferred" block; full per-file diffing arrives with
16
16
  # slice 2's `--write` merge.
17
- # - `:json` — machine-readable payload with the same classification table as `:print`.
17
+ # - `:json` — machine-readable payload with the same classification table as `:print`, plus every `skipped`
18
+ # row with its `skip_reason` (#778).
18
19
  class Renderer
19
20
  def initialize(out:)
20
21
  @out = out
@@ -27,11 +28,10 @@ module Rigor
27
28
  # {Classification} constants to include; an empty
28
29
  # array means "all emittable classifications".
29
30
  def render(candidates:, mode:, format:, selection:)
30
- filtered = filter(candidates, selection)
31
-
32
31
  case format
33
- when "json" then render_json(filtered)
32
+ when "json" then render_json(filter(candidates, selection, with_skipped: true))
34
33
  when "text"
34
+ filtered = filter(candidates, selection)
35
35
  mode == :diff ? render_diff(filtered) : render_print(filtered)
36
36
  else
37
37
  raise ArgumentError, "unsupported format: #{format}"
@@ -40,9 +40,16 @@ module Rigor
40
40
 
41
41
  private
42
42
 
43
- def filter(candidates, selection)
43
+ # The emittable rows the selection asks for. JSON also carries every `skipped` row whatever the selection:
44
+ # ADR-14 makes the JSON payload the surface where `sig.skipped.*` is reported, and a consumer asking why a
45
+ # method is missing from its `sig/` needs the reason next to the rows that did emit (#778 — the rows were
46
+ # built with their `skip_reason` and then dropped here). `equivalent` rows stay out: nothing to do,
47
+ # nothing to explain.
48
+ def filter(candidates, selection, with_skipped: false)
44
49
  active = selection.empty? ? Classification::EMITTABLE : selection
45
- candidates.select { |c| active.include?(c.classification) }
50
+ candidates.select do |c|
51
+ active.include?(c.classification) || (with_skipped && c.classification == Classification::SKIPPED)
52
+ end
46
53
  end
47
54
 
48
55
  def render_print(candidates)
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.3.7"
4
+ VERSION = "0.3.8"
5
5
  end
@@ -13,6 +13,12 @@ module Rigor
13
13
  attr_reader synthetic_method_index: untyped?
14
14
  attr_reader project_patched_methods: untyped?
15
15
  attr_reader hkt_registry: untyped?
16
+ # Issue #784 — `[error_class_name, first_message_line, raw_frame_or_nil, stage]` when either stage of
17
+ # the HKT-registry build raised at its seam (`:scan`, the RBS `type`-alias scan; `:overlay`, the
18
+ # plugin-manifest aggregation — #791), nil when both built or were never demanded. Declared `untyped?`
19
+ # like its neighbours:
20
+ # `rigor sig-gen` emits nothing for this delegating reader, and the tuple shape is documented here.
21
+ def hkt_scan_failure: () -> untyped?
16
22
 
17
23
  def self.default: () -> Environment
18
24
  def self.for_project: (?root: String, ?libraries: Array[String], ?signature_paths: Array[String | _ToPath]?, ?cache_store: untyped?, ?plugin_registry: untyped?, ?dependency_source_index: untyped?, ?rbs_extended_reporter: untyped?, ?boundary_cross_reporter: untyped?, ?source_rbs_synthesis_reporter: untyped?, ?bundler_bundle_path: String?, ?bundler_auto_detect: bool, ?synthetic_method_index: untyped?, ?project_patched_methods: untyped?) -> Environment