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.
@@ -84,11 +84,14 @@ module Rigor
84
84
  # sigs are Rigor-shipped and trusted, so they stay on the loader's fast batch path.
85
85
  add_bundled_signatures(rbs_loader, loaded_libraries.to_set(&:to_s))
86
86
  env = RBS::Environment.from_loader(rbs_loader)
87
+ project_files = project_sig_files(signature_paths)
87
88
  add_project_signatures(env, signature_paths, deferred_signature_paths)
88
89
  add_virtual_rbs(env, virtual_rbs)
89
90
  synthesize_missing_namespaces(env)
90
- env, resolved = resolve_quarantining_virtual_collisions(env, virtual_rbs)
91
- stub_missing_referenced_types(env, resolved, project_sig_files(signature_paths))
91
+ # Issue #777 — resolve-time backstop: also unload colliding PROJECT buffers (class-vs-module /
92
+ # constant redeclarations against bundled RBS). Virtual culprits are preferred when both appear.
93
+ env, resolved = resolve_quarantining_virtual_collisions(env, virtual_rbs, project_files: project_files)
94
+ stub_missing_referenced_types(env, resolved, project_files)
92
95
  end
93
96
 
94
97
  # rbs ships `stdlib/bigdecimal/` and `stdlib/bigdecimal-math/` as two libraries, so
@@ -157,32 +160,61 @@ module Rigor
157
160
  false
158
161
  end
159
162
 
160
- # Backstop for a virtual-vs-anything `RBS::DuplicatedDeclarationError` that only materialises at
161
- # `resolve_type_names` (which rebuilds the env from `sources`). {.add_virtual_rbs}'s transactional
162
- # rescue already handles the add-time case — empirically everything on rbs 4.x — but the rbs gemspec
163
- # range spans `>= 3.0, < 5.0` (ADR-79) and WHERE duplicate detection fires is an rbs-internal choice
164
- # this code must not depend on. Resolution rule is the same as the add-time path: the explicit
165
- # signature wins, the colliding VIRTUAL buffer is dropped whole (`RBS::Environment#unload`) and
166
- # resolution retries; every pass removes at least one virtual buffer, so the loop is bounded by the
167
- # virtual-entry count. A duplicate involving no virtual buffer (sig-vs-sig), or an env without
168
- # `#unload` (rbs 3.x), re-raises into the existing one-warning degrade path.
163
+ # Backstop for a `RBS::DuplicatedDeclarationError` that only materialises at `resolve_type_names`
164
+ # (which rebuilds the env from `sources`). {.add_virtual_rbs} / {.add_project_parsed_decls}
165
+ # transactionally handle the add-time case — empirically everything on rbs 4.x — but the rbs
166
+ # gemspec range spans `>= 3.0, < 5.0` (ADR-79) and WHERE duplicate detection fires is an
167
+ # rbs-internal choice this code must not depend on. Resolution preference: drop colliding VIRTUAL
168
+ # buffers first (explicit `.rbs` wins), else drop colliding PROJECT `signature_paths:` buffers
169
+ # (issue #777 bundled RBS wins over a kind-colliding project file), then retry. Every pass
170
+ # removes at least one buffer, so the loop is bounded. A duplicate with neither virtual nor
171
+ # project culprits (bundled-vs-bundled), or an env without `#unload` (rbs 3.x), re-raises into
172
+ # the existing one-warning total-failure path.
169
173
  #
170
174
  # The dropped set is not returned: consumers recover it from the built env via
171
175
  # {#virtual_rbs_collision_quarantined}, which also works on a cache HIT where this build never ran.
172
- def resolve_quarantining_virtual_collisions(env, virtual_rbs)
176
+ def resolve_quarantining_virtual_collisions(env, virtual_rbs, project_files: [])
173
177
  virtual_names = virtual_rbs.to_set { |name, _content| name.to_s }
174
- (virtual_names.size + 1).times do
178
+ project_names = Array(project_files).to_set(&:to_s)
179
+ # Bound by virtual + project culprit candidates: each pass removes at least one buffer.
180
+ (virtual_names.size + project_names.size + 1).times do
175
181
  return [env, env.resolve_type_names]
176
182
  rescue ::RBS::DuplicatedDeclarationError => e
177
- raise unless env.respond_to?(:unload)
183
+ env = unload_duplicated_declaration_culprits(
184
+ env, e, virtual_names: virtual_names, project_names: project_names
185
+ )
186
+ end
187
+ [env, env.resolve_type_names]
188
+ end
189
+
190
+ # Prefer dropping colliding VIRTUAL buffers (explicit `.rbs` wins), else PROJECT `signature_paths:`
191
+ # buffers (issue #777 — bundled RBS wins over a kind-colliding project file). A duplicate with
192
+ # neither virtual nor project culprits (bundled-vs-bundled), or an env without `#unload`, re-raises.
193
+ def unload_duplicated_declaration_culprits(env, error, virtual_names:, project_names:)
194
+ raise unless env.respond_to?(:unload)
178
195
 
179
- culprits = e.decls.filter_map { |decl| decl.location&.buffer&.name }
180
- .uniq.select { |name| virtual_names.include?(name) }
181
- raise if culprits.empty?
196
+ culprits = duplicated_declaration_buffer_names(error)
197
+ virtual_culprits = culprits.select { |name| virtual_names.include?(name.to_s) }
198
+ return env.unload(virtual_culprits) unless virtual_culprits.empty?
199
+
200
+ # Prefer dropping the project buffer(s): the bundled declaration stays, Greeter/core stay
201
+ # usable, and only the conflicting file is absent.
202
+ project_culprits = select_project_collision_culprits(culprits, project_names)
203
+ raise if project_culprits.empty?
204
+
205
+ env.unload(project_culprits)
206
+ end
182
207
 
183
- env = env.unload(culprits)
208
+ def duplicated_declaration_buffer_names(error)
209
+ error.decls.filter_map { |decl| decl.location&.buffer&.name }.uniq
210
+ end
211
+
212
+ def select_project_collision_culprits(culprits, project_names)
213
+ culprits.select do |name|
214
+ project_names.include?(File.expand_path(name.to_s))
215
+ rescue ArgumentError, TypeError
216
+ project_names.include?(name.to_s)
184
217
  end
185
- [env, env.resolve_type_names]
186
218
  end
187
219
 
188
220
  # ADR-5 robustness, second tier. A project `signature_paths:` RBS that *references* a type no loaded
@@ -298,11 +330,26 @@ module Rigor
298
330
  next if parsed.nil? # quarantined (unparseable) or unreadable — skip so the env survives
299
331
 
300
332
  buffer, directives, decls = parsed
301
- add_parsed_decls(env, buffer, directives, decls)
333
+ add_project_parsed_decls(env, buffer, directives, decls)
302
334
  end
303
335
  add_deferred_signatures(env, deferred)
304
336
  end
305
337
 
338
+ # Issue #777 — add one project signature transactionally. `RBS::Environment#add_source` appends to
339
+ # `sources` BEFORE inserting decls, so a mid-insert `RBS::DuplicatedDeclarationError` (class-vs-module
340
+ # kind collision against bundled RBS, or a constant redeclared from core) would otherwise leave a
341
+ # poisoned source that `resolve_type_names` re-raises outside every per-file rescue, collapsing the
342
+ # WHOLE env to nil. Mirror {.add_virtual_rbs}: drop the poisoned source and continue so Greeter /
343
+ # core / every non-conflicting project file still load. Without a `sources` table (rbs 3.x) re-raise
344
+ # into the existing total-failure path — we cannot make the skip transactional there.
345
+ def add_project_parsed_decls(env, buffer, directives, decls)
346
+ add_parsed_decls(env, buffer, directives, decls)
347
+ rescue ::RBS::DuplicatedDeclarationError
348
+ raise unless env.respond_to?(:sources)
349
+
350
+ env.sources.reject! { |source| source.buffer.name == buffer.name }
351
+ end
352
+
306
353
  # Issue #610 — the DEFERRED half: signature files a bundled plugin contributes through its manifest,
307
354
  # added after every other source so each can be asked a question the eager half cannot be asked —
308
355
  # "does something already declare this class, at a different generic arity?".
@@ -331,7 +378,7 @@ module Rigor
331
378
  buffer, directives, decls = parsed
332
379
  next if generic_arity_conflict(env, decls)
333
380
 
334
- add_parsed_decls(env, buffer, directives, decls)
381
+ add_project_parsed_decls(env, buffer, directives, decls)
335
382
  end
336
383
  end
337
384
 
@@ -436,6 +483,55 @@ module Rigor
436
483
  end
437
484
  end
438
485
 
486
+ # Issue #777 — project `signature_paths:` files that PARSE but are absent from the built env because
487
+ # {.add_project_parsed_decls} / {.resolve_quarantining_virtual_collisions} quarantined a duplicated
488
+ # declaration (typically class-vs-module or a constant already shipped by bundled RBS). Derived from
489
+ # the env like {#virtual_rbs_collision_quarantined}, so a cache HIT reports the same condition.
490
+ #
491
+ # Path membership alone is not enough: the suite's {RbsEnvMemo} reuses a byte-identical env across
492
+ # `Dir.mktmpdir` roots when `signature_paths:` are relative (`sig/sink.rbs`), leaving buffers named
493
+ # after the FIRST path. A later example's absolute path is then absent from `present` even though
494
+ # the declarations loaded — that used to spam `duplicated declaration against bundled RBS` banners
495
+ # (~60 per CI shard) for fixtures that never collided. Basename + content equality catches that
496
+ # reuse without hiding a real quarantine (e.g. project `class Base64` vs bundled `module Base64`,
497
+ # same basename, different bytes).
498
+ COLLISION_QUARANTINE_NOTE =
499
+ "duplicated declaration against bundled RBS — quarantined so the rest of the RBS env still loads"
500
+
501
+ def collision_quarantined_project_signatures(env, signature_paths)
502
+ return [] if env.nil?
503
+
504
+ present = env.buffers.to_set do |buffer|
505
+ name = buffer.name
506
+ next name.to_s unless name
507
+
508
+ File.expand_path(name.to_s)
509
+ rescue ArgumentError, TypeError
510
+ name.to_s
511
+ end
512
+ buffers_by_basename = env.buffers.group_by { |buffer| File.basename(buffer.name.to_s) }
513
+ project_sig_files(signature_paths).sort.filter_map do |file|
514
+ next if present.include?(file)
515
+ next if parse_signature_file(file).nil? # parse / encoding quarantine owns these
516
+ next if project_signature_loaded_under_other_path?(file, buffers_by_basename)
517
+
518
+ [file, "#{file}: #{COLLISION_QUARANTINE_NOTE}"]
519
+ end
520
+ end
521
+
522
+ # True when `file`'s bytes are already in `env` under a different absolute path (same basename).
523
+ # See {#collision_quarantined_project_signatures} — keeps memoised env reuse from looking like a
524
+ # collision quarantine.
525
+ def project_signature_loaded_under_other_path?(file, buffers_by_basename)
526
+ peers = buffers_by_basename[File.basename(file)]
527
+ return false if peers.nil? || peers.empty?
528
+
529
+ content = File.read(file, encoding: "UTF-8")
530
+ peers.any? { |buffer| buffer.content == content }
531
+ rescue Errno::ENOENT, Errno::EISDIR, Errno::EACCES
532
+ false
533
+ end
534
+
439
535
  # The `::`-stripped names of every type a PROJECT signature references that no loaded declaration
440
536
  # provides — the input to {.append_stub_declarations}.
441
537
  #
@@ -1000,8 +1096,10 @@ module Rigor
1000
1096
  # `.freeze`d (per ADR-15 reflection-facade contract) without losing the lazy-memo behaviour. Slot
1001
1097
  # names currently consulted: `:env`, `:env_loaded`, `:env_build_warned`, `:definition_build_warned`,
1002
1098
  # `:definition_build_details`, `:definition_build_reported`, `:definition_build_failures`,
1003
- # `:internal_demand`, `:builder`, `:reflection`, `:instance_definitions_table`,
1004
- # `:singleton_definitions_table`.
1099
+ # `:definition_build_deferred_count`, `:definition_build_deferred_first`,
1100
+ # `:definition_build_summary_warned`, `:internal_demand`, `:internal_demand_status`, `:builder`,
1101
+ # `:reflection`,
1102
+ # `:instance_definitions_table`, `:singleton_definitions_table`.
1005
1103
  # Constructed via `Hash.new` (NOT a `{ ... }` literal) so Rigor's `HashShape` narrowing doesn't
1006
1104
  # infer a fixed key set from the initial state and fold post-initial slot reads (e.g.
1007
1105
  # `@state[:env_loaded]`) to a constant `nil`.
@@ -1031,7 +1129,17 @@ module Rigor
1031
1129
  #
1032
1130
  # @return [Array<Array(String, String)>] empty when every `signature_paths:` file parses.
1033
1131
  def quarantined_signatures
1034
- @state[:quarantined] ||= self.class.quarantined_project_signatures(@signature_paths).freeze
1132
+ @state[:quarantined] ||= begin
1133
+ parse_quarantined = self.class.quarantined_project_signatures(@signature_paths)
1134
+ collision_quarantined = self.class.collision_quarantined_project_signatures(
1135
+ @state[:env], @signature_paths
1136
+ )
1137
+ parse_paths = parse_quarantined.to_set { |path, _note| path }
1138
+ (
1139
+ parse_quarantined +
1140
+ collision_quarantined.reject { |entry| parse_paths.include?(entry[0]) }
1141
+ ).freeze
1142
+ end
1035
1143
  end
1036
1144
 
1037
1145
  # Issue #610 — plugin-contributed signature files that stood down against a colliding generic arity,
@@ -1083,12 +1191,13 @@ module Rigor
1083
1191
  end
1084
1192
 
1085
1193
  # The total RBS-environment build failure captured this run, or nil when the env built. Unlike
1086
- # {#quarantined_signatures} — which the env survives, one file lighter, and which is re-derived by
1087
- # re-parsing so a cache HIT reports it too — a total failure (typically `RBS::DuplicatedDeclarationError`:
1088
- # a `signature_paths:` entry redeclaring a constant/class Rigor's bundled RBS already ships) collapses the
1089
- # WHOLE env to nil. A failed build produces no cached success to hide behind (nothing is persisted, so
1090
- # every run re-attempts and re-raises), so this is captured directly in {#env}'s rescue rather than
1091
- # re-derived. Forcing `env` (any query does) populates it.
1194
+ # {#quarantined_signatures} — which the env survives, one file lighter, and which is re-derived so a
1195
+ # cache HIT reports it too — a total failure collapses the WHOLE env to nil. Project-vs-bundled
1196
+ # `RBS::DuplicatedDeclarationError`s are quarantined per-file since #777; this slot remains for
1197
+ # unrecoverable build errors (e.g. bundled-vs-bundled collisions, or hosts without a transactional
1198
+ # `sources`/`unload` API). A failed build produces no cached success to hide behind (nothing is
1199
+ # persisted, so every run re-attempts and re-raises), so this is captured directly in {#env}'s rescue
1200
+ # rather than re-derived. Forcing `env` (any query does) populates it.
1092
1201
  #
1093
1202
  # @return [Array(String, String, Array<String>), nil] `[error_class_name, first_error_line,
1094
1203
  # conflicting_buffer_names]`, or nil when the environment built successfully.
@@ -1736,11 +1845,10 @@ module Rigor
1736
1845
  end
1737
1846
 
1738
1847
  # The RBS environment for this loader. Memoised both on success AND on failure: when the env build
1739
- # raises (typically `RBS::DuplicatedDeclarationError` because a `signature_paths:` entry redeclares a
1740
- # constant or class already shipped by stdlib RBS), retrying on every subsequent `env` call would
1741
- # re-parse and re-resolve the whole sig set per AST node touched during analysis, multiplying per-file
1742
- # analysis cost by ~100x. Failures short-circuit to `nil` here and are surfaced to the user via
1743
- # `warn_about_env_build_failure_once` so the broken `signature_paths:` entry is identifiable.
1848
+ # raises an *unrecoverable* `RBS::BaseError` (project-vs-bundled duplicates are quarantined per-file
1849
+ # since #777), retrying on every subsequent `env` call would re-parse and re-resolve the whole sig set
1850
+ # per AST node touched during analysis, multiplying per-file analysis cost by ~100x. Failures
1851
+ # short-circuit to `nil` here and are surfaced to the user via `warn_about_env_build_failure_once`.
1744
1852
  def env
1745
1853
  return @state[:env] if @state[:env_loaded]
1746
1854
 
@@ -1778,10 +1886,11 @@ module Rigor
1778
1886
  lines = listed.map { |_path, first_line| " - #{first_line}" }
1779
1887
  lines << " … and #{more} more" if more.positive?
1780
1888
  warn(
1781
- "rigor: skipped #{quarantined.size} unparseable RBS file(s) under `signature_paths:`.\n " \
1782
- "They were QUARANTINED so the rest of your RBS env still loads, but the types they\n " \
1783
- "declare are absent — calls into them read `Dynamic[top]`, so coverage and diagnostics\n " \
1784
- "are reduced. Fix the parse error(s) to restore that coverage:\n" \
1889
+ "rigor: skipped #{quarantined.size} RBS file(s) under `signature_paths:` (unparseable or\n " \
1890
+ "duplicated against bundled RBS). They were QUARANTINED so the rest of your RBS env\n " \
1891
+ "still loads, but the types they declare are absent — calls into them read\n " \
1892
+ "`Dynamic[top]`, so coverage and diagnostics are reduced. Fix the parse error(s) or\n " \
1893
+ "remove the conflicting declaration(s) to restore that coverage:\n" \
1785
1894
  "#{lines.join("\n")}"
1786
1895
  )
1787
1896
  end
@@ -1870,8 +1979,8 @@ module Rigor
1870
1979
  #
1871
1980
  # {#during_internal_demand} (issue #696) — these two walk EVERY known class, so a failure they hit is a
1872
1981
  # failure of the sig set, not of anything the run asked about, and it must not reach
1873
- # {#definition_build_failures}. The stderr banner is deliberately left armed here, byte-for-byte as
1874
- # before.
1982
+ # {#definition_build_failures}. The stderr fallback stays armed, but {#during_internal_demand} folds
1983
+ # every failure found by one outermost walk into one bounded summary (issue #718).
1875
1984
  def instance_definitions_table
1876
1985
  @state[:instance_definitions_table] ||= during_internal_demand do
1877
1986
  build_definitions_table { |name| build_instance_definition(name) }
@@ -2005,7 +2114,9 @@ module Rigor
2005
2114
  # review, second pass).
2006
2115
  #
2007
2116
  # Save-and-restore rather than a bare flag: `#prewarm` wraps a body whose members wrap themselves, and
2008
- # a nested demand must not un-mark its caller on the way out.
2117
+ # a nested demand must not un-mark its caller on the way out. A successful outermost exit flushes the
2118
+ # deferred stderr summary; an outermost exit that raises keeps the detailed fallback banner armed even
2119
+ # when an earlier internal-demand episode already emitted the loader-instance summary.
2009
2120
  #
2010
2121
  # Per LOADER, not per thread. Nesting and a raise mid-demand are both handled, and the fork pool forks
2011
2122
  # after `#prewarm` returns, so no CLI path shares a loader across concurrent analyses. An in-process
@@ -2013,10 +2124,29 @@ module Rigor
2013
2124
  # silence another's reporting; not reachable today, and not worth a thread-local until it is.
2014
2125
  def during_internal_demand
2015
2126
  previous = @state[:internal_demand]
2127
+ outermost = !previous
2128
+ if outermost
2129
+ @state[:definition_build_deferred_count] = 0
2130
+ @state[:definition_build_deferred_first] = nil
2131
+ end
2016
2132
  @state[:internal_demand] = true
2017
- yield
2018
- ensure
2019
- @state[:internal_demand] = previous
2133
+ # `rescue`/`else` keeps the normal block result precise for the analyzer; the pending marker covers
2134
+ # non-local exits (e.g. `throw`) that unwind through `ensure` without entering either branch.
2135
+ @state[:internal_demand_status] = :pending if outermost
2136
+ begin
2137
+ result = yield
2138
+ @state[:internal_demand_status] = :completed if outermost
2139
+ rescue StandardError, ScriptError => e
2140
+ @state[:internal_demand_status] = :aborted if outermost
2141
+ warn_about_aborted_definition_build_failures if outermost
2142
+ raise e
2143
+ else
2144
+ warn_about_deferred_definition_build_failures if outermost
2145
+ result
2146
+ ensure
2147
+ @state[:internal_demand] = previous
2148
+ warn_about_aborted_definition_build_failures if outermost && @state[:internal_demand_status] == :pending
2149
+ end
2020
2150
  end
2021
2151
 
2022
2152
  # The third twin of {#warn_about_quarantined_signatures} / {#warn_about_virtual_rbs_collisions}: name,
@@ -2029,21 +2159,33 @@ module Rigor
2029
2159
  # {#env}, because the whole env is built eagerly and every quarantine/collision is already known by
2030
2160
  # then. Definition builds are LAZY (ADR-54 WD1 — built on demand per class the FIRST time a caller asks,
2031
2161
  # long after {#env} has already run), so there is no later central checkpoint to fire from before the
2032
- # affected classes even exist. This warns inline at the rescue site instead, gated on
2033
- # `@state[:definition_build_warned]` (keyed by class name) so the instance and singleton sides and
2034
- # any re-entry once the per-process `@instance_definition_cache` / `@singleton_definition_cache`
2035
- # memoize the failure warn at most once per class name, cache-hit runs included (a definition build
2036
- # is per-process regardless of the RBS-env cache tier). `@state` is per-LOADER-INSTANCE, not
2037
- # process-global, so under the fork-based analysis pool each worker holds its own loader and its own
2038
- # `@state`: a class whose definition fails can print its warning once per worker that happens to touch
2039
- # it, i.e. more than once in a single `rigor check` run. Deduplicating that across processes is out of
2040
- # scope here see [#295](https://github.com/rigortype/rigor/issues/295).
2162
+ # affected classes even exist. An ordinary demand therefore warns inline at the rescue site; Rigor's
2163
+ # own internal demand defers until its outermost boundary and collapses the whole walk to one summary
2164
+ # (issue #718). Both routes share `@state[:definition_build_warned]` (keyed by normalized class name),
2165
+ # so the instance and singleton sides and any re-entry once the per-loader-instance definition caches memoize
2166
+ # the failure count or warn at most once per class name, cache-hit runs included.
2167
+ #
2168
+ # `@state` is per LOADER INSTANCE, not process-global. A fork-pool prewarm finishes before the fork, so
2169
+ # that loader instance emits its summary once in the parent and its dedupe state is inherited by the
2170
+ # workers. A separate loader instance (for example, a parameter-inference pre-pass) has its own summary
2171
+ # budget. A failure first reached by an ordinary demand can still warn once in each worker that reaches
2172
+ # it; deduplicating that across processes is out of scope here — see [#295](https://github.com/rigortype/rigor/issues/295).
2041
2173
  def warn_about_definition_build_failure(class_name, error)
2042
2174
  warned = (@state[:definition_build_warned] ||= {})
2043
- key = class_name.to_s
2175
+ key = class_name.to_s.delete_prefix("::")
2044
2176
  return if warned[key]
2045
2177
 
2046
2178
  warned[key] = true
2179
+ if @state[:internal_demand]
2180
+ @state[:definition_build_deferred_count] = @state.fetch(:definition_build_deferred_count, 0) + 1
2181
+ @state[:definition_build_deferred_first] ||= [class_name.to_s, error]
2182
+ return
2183
+ end
2184
+
2185
+ warn(definition_build_failure_warning(class_name, error))
2186
+ end
2187
+
2188
+ def definition_build_failure_warning(class_name, error)
2047
2189
  first_line = error.message.to_s.lines.first.to_s.strip
2048
2190
  buffers = definition_build_conflict_buffers(error)
2049
2191
  collisions =
@@ -2056,13 +2198,42 @@ module Rigor
2056
2198
  lines << " … and #{more} more" if more.positive?
2057
2199
  "\n Colliding declaration(s):\n#{lines.join("\n")}"
2058
2200
  end
2059
- warn(
2060
- "rigor: RBS definition build failed for `#{class_name}`: #{error.class}: #{first_line}\n " \
2201
+ "rigor: RBS definition build failed for `#{class_name}`: #{error.class}: #{first_line}\n " \
2061
2202
  "Rigor still treats the class as known, so calls into it now silently degrade to\n " \
2062
2203
  "`Dynamic[top]` — real methods and typos alike — instead of resolving normally.#{collisions}"
2204
+ end
2205
+
2206
+ def warn_about_deferred_definition_build_failures
2207
+ return if @state[:definition_build_summary_warned]
2208
+
2209
+ count = @state[:definition_build_deferred_count]
2210
+ return if count.nil? || count.zero?
2211
+
2212
+ @state[:definition_build_summary_warned] = true
2213
+ first_class, first_error = @state[:definition_build_deferred_first]
2214
+ affected = if count == 1
2215
+ "1 class affected"
2216
+ else
2217
+ more = count - 1
2218
+ noun = more == 1 ? "class" : "classes"
2219
+ "… and #{more} more #{noun} affected (#{count} total)"
2220
+ end
2221
+ warn(
2222
+ "#{definition_build_failure_warning(first_class, first_error)}\n " \
2223
+ "Internal whole-universe demand found #{affected}; the " \
2224
+ "`rbs.coverage.definition-build-failed` diagnostic reports the classes the analysis demanded and " \
2225
+ "the culprit member and signature files."
2063
2226
  )
2064
2227
  end
2065
2228
 
2229
+ def warn_about_aborted_definition_build_failures
2230
+ count = @state[:definition_build_deferred_count]
2231
+ return if count.nil? || count.zero?
2232
+
2233
+ first_class, first_error = @state[:definition_build_deferred_first]
2234
+ warn(definition_build_failure_warning(first_class, first_error))
2235
+ end
2236
+
2066
2237
  # The definition-build twin of {#env_build_conflict_buffers}: the declaration source file(s) named by a
2067
2238
  # `RBS::DefinitionBuilder` failure, so {#warn_about_definition_build_failure} can name the colliding
2068
2239
  # declarations rather than only the exception's class and message. The payload shape varies by error
@@ -8,6 +8,7 @@ require_relative "environment/rbs_loader"
8
8
  require_relative "environment/reflection"
9
9
  require_relative "environment/reporters"
10
10
  require_relative "environment/hkt_registry_holder"
11
+ require_relative "environment/failure_slot"
11
12
  require_relative "environment/constant_type_cache_holder"
12
13
  require_relative "environment/missing_gem_constant_index"
13
14
  require_relative "environment/bundle_sig_discovery"
@@ -88,6 +89,10 @@ module Rigor
88
89
  # --no-stats` from doing the RBS env build at all.
89
90
  @hkt_registry_base = hkt_registry || Inference::HktRegistry::EMPTY
90
91
  @hkt_registry_holder = HktRegistryHolder.new
92
+ # Issue #784 — where either stage of the HKT-registry build (the plugin overlay, #791; the RBS
93
+ # `type`-alias scan) records a raise, so it surfaces once for the run instead of once per file — or,
94
+ # for the overlay, instead of aborting the run outright (see {#hkt_registry}).
95
+ @hkt_scan_failure = FailureSlot.new
91
96
  @constant_type_cache = ConstantTypeCacheHolder.new
92
97
  # ADR-82 WD9 — `[gem_name, version]` pairs for the locked gems with no resolvable RBS. The
93
98
  # root-constant ownership index over them is built lazily (first unresolved constant read) so runs
@@ -104,21 +109,93 @@ module Rigor
104
109
  # beat plugin entries, which beat the bundled JSON_VALUE. Memoised; single-threaded use only (under the
105
110
  # Ractor pool path each worker has its own Environment so cross-worker mutation is impossible; the LSP
106
111
  # single-publish-at-a-time invariant serialises here).
112
+ #
113
+ # BOTH stages are guarded (#784, #791): whichever raises is recorded on {#hkt_scan_failure} with the
114
+ # stage that failed and the other stage still runs, so this getter never raises into its callers —
115
+ # neither into a file's `analyze_body` rescue nor out of the run-owned demands, which have no rescue
116
+ # above them at all.
107
117
  def hkt_registry
108
118
  @hkt_registry_holder.fetch do
109
- with_plugin_overlay = if @plugin_registry.respond_to?(:hkt_overlay_registry)
110
- @hkt_registry_base.merge(@plugin_registry.hkt_overlay_registry)
111
- else
112
- @hkt_registry_base
113
- end
114
- Inference::HktRegistry.scan_rbs_loader(
115
- @rbs_loader,
116
- base: with_plugin_overlay,
117
- reporter: rbs_extended_reporter
118
- )
119
+ pre_scan = pre_scan_hkt_registry
120
+ begin
121
+ Inference::HktRegistry.scan_rbs_loader(
122
+ @rbs_loader,
123
+ base: pre_scan,
124
+ reporter: rbs_extended_reporter
125
+ )
126
+ rescue StandardError => e
127
+ # Issue #784 — the seam. This build is shared and memoised, and it is first demanded from inside
128
+ # a file's analysis, so a raise here would otherwise land in every file's `analyze_body` rescue
129
+ # and turn the whole run into N identical `internal analyzer error` rows (issue #776). Record it
130
+ # and degrade to the pre-scan registry: analysis proceeds, HKT inference from RBS `type` aliases
131
+ # reads its bound (`Dynamic[top]`, ADR-20 WD2), and the run surfaces ONE
132
+ # `rbs.coverage.hkt-scan-failed` row. Not a silent skip — the row is `:error` and names the
133
+ # exception and raise site — and the same shape as `RbsLoader#record_env_build_failure`.
134
+ # Memoising the degraded registry is what keeps the scan from being re-attempted per file.
135
+ record_hkt_registry_failure(e, stage: :scan)
136
+ pre_scan
137
+ end
119
138
  end
120
139
  end
121
140
 
141
+ # Issue #791 — the OTHER build behind {#hkt_registry}, and the reason the seam covers two stages
142
+ # rather than one. Aggregating the loaded plugins' manifest-declared HKT entries runs plugin-authored
143
+ # code (`Plugin::Registry#hkt_overlay_registry` reads each manifest); before this it sat one line ABOVE
144
+ # the #784 rescue, so a raise there escaped {#hkt_registry} entirely. Post-#788 that is not a per-file
145
+ # crash storm but an uncaught abort: {HktRegistryHolder#fetch} memoises only on success, so the raise
146
+ # is re-attempted at every demand, and the run-owned demands (`Runner::PoolCoordinator#hkt_scan_outcome`
147
+ # after the file loop, `Analysis::WorkerSession#drain_reporters`) sit outside any rescue.
148
+ #
149
+ # The degradation is narrower than the scan's: only the plugin overlay is dropped, and the RBS scan
150
+ # still runs on top of the bundled base, so a user's own `.rbs` HKT registrations survive a plugin
151
+ # defect. First-write-wins on the slot means a run whose overlay AND scan both raise reports the
152
+ # overlay — the actionable one, since the plugin is what a user can remove.
153
+ #
154
+ # @return [Rigor::Inference::HktRegistry] the registry the RBS scan is layered on top of.
155
+ def pre_scan_hkt_registry
156
+ return @hkt_registry_base unless @plugin_registry.respond_to?(:hkt_overlay_registry)
157
+
158
+ @hkt_registry_base.merge(@plugin_registry.hkt_overlay_registry)
159
+ rescue StandardError => e
160
+ record_hkt_registry_failure(e, stage: :overlay)
161
+ @hkt_registry_base
162
+ end
163
+ private :pre_scan_hkt_registry
164
+
165
+ # Issue #784 — the `[error_class_name, first_message_line, raw_frame_or_nil, stage]` tuple the seam in
166
+ # {#hkt_registry} recorded, or nil when both stages built (or were never demanded). `stage` is `:scan`
167
+ # for the RBS `type`-alias scan and `:overlay` for the plugin-manifest aggregation (#791); it decides
168
+ # the row's wording, because "the implicit HKT scan over RBS `type` aliases raised" would point a user
169
+ # at their own `.rbs` for a plugin's defect. The run reads the tuple only after demanding
170
+ # {#hkt_registry} itself — after the file loop on the sequential paths, at drain time in each pool
171
+ # worker (the coordinator never analyses a file under the pool, so the drain is how its snapshot learns
172
+ # the outcome: the #696 lesson), and from a resolved environment when the analyze set is empty — so the
173
+ # row never depends on which files happened to be analysed.
174
+ #
175
+ # @return [Array(String, String, String, Symbol), Array(String, String, nil, Symbol), nil]
176
+ def hkt_scan_failure
177
+ @hkt_scan_failure.value
178
+ end
179
+
180
+ def record_hkt_registry_failure(error, stage:)
181
+ frames = error.backtrace || []
182
+ frame = frames.find { |f| f.include?("/lib/rigor/") } || frames.first
183
+ # `Class#name` is nil for an anonymous exception class; its nearest NAMED ancestor is the stable
184
+ # description (`inspect` embeds an object address, which would make the row's text differ across
185
+ # processes). Each String is frozen individually, not just the Array: the tuple crosses the fork
186
+ # boundary Marshal-clean and the drain channel's stated invariant
187
+ # ({Analysis::WorkerSession#drain_reporters}) is that its payload is also `Ractor.shareable?`, which a
188
+ # shallow freeze over `chomp`'s fresh String would not satisfy.
189
+ named = error.class.ancestors.find { |a| a.is_a?(Class) && a.name }
190
+ @hkt_scan_failure.record([
191
+ (error.class.name || "anonymous #{named&.name || 'Exception'}").dup.freeze,
192
+ error.message.to_s.lines.first.to_s.chomp.freeze,
193
+ frame&.dup&.freeze,
194
+ stage
195
+ ])
196
+ end
197
+ private :record_hkt_registry_failure
198
+
122
199
  # ADR-82 WD9 — the gem name owning `root_constant_name`, when that gem is locked in the project's
123
200
  # Gemfile.lock, ships no resolvable RBS, and its entry file declares the constant at top level. Nil for
124
201
  # everything else — the caller then keeps the generic provenance cause. The index is built once, lazily,
@@ -150,7 +150,9 @@ module Rigor
150
150
  # loaded RBS env, parse them via {Rigor::RbsExtended::HktDirectives}, and return a new
151
151
  # registry that is the union of `base` and every parsed entry. Last-write-wins on URI
152
152
  # collisions per {#merge}'s contract. Fail-soft on per-annotation parse errors (the reporter
153
- # records an `:info` entry; the other annotations still apply).
153
+ # records an entry the run surfaces as one `dynamic.rbs-extended.hkt-directive-invalid` `:info`
154
+ # diagnostic; the other annotations still apply). That claim was false until issue #785: the
155
+ # production reporter has no `#record`, so every declined directive was dropped silently.
154
156
  #
155
157
  # @param rbs_loader [Rigor::Environment::RbsLoader]
156
158
  # @param base [HktRegistry] starting registry (typically the bundled
@@ -158,8 +160,9 @@ module Rigor
158
160
  # @param name_scope [Rigor::Environment::NameScope, nil] threaded through to the bound
159
161
  # resolver for class-name lookups; safe to omit during scanning since hkt bounds are
160
162
  # typically `untyped` or stdlib classes.
161
- # @param reporter [#record, nil] same fail-soft reporter contract the other RBS-extended
162
- # parsers use.
163
+ # @param reporter [Rigor::RbsExtended::Reporter, #record, nil] same fail-soft reporter contract
164
+ # the other RBS-extended parsers use; a collecting double that responds only to `#record` /
165
+ # `#<<` still works (see {Rigor::RbsExtended::HktDirectives.record_hkt_error}).
163
166
 
164
167
  # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
165
168
  def self.scan_rbs_loader(rbs_loader, base: EMPTY, name_scope: nil, reporter: nil)
@@ -198,7 +201,16 @@ module Rigor
198
201
  params = decl.type_params.map(&:name)
199
202
  params_set = params.to_set
200
203
 
201
- translator = HktSugarTranslator.new(uri: uri, params_set: params_set, name_scope: name_scope)
204
+ # No rescue around this translate. HktSugarTranslator degrades every alias shape it
205
+ # cannot model (a non-recursive alias, a malformed self-reference) to a leaf, so a
206
+ # parseable `type` alias never raises here — that is the fix for issue #776, where an
207
+ # unknown keyword aborted this shared, memoised build and turned every file into an
208
+ # `internal analyzer error`. An exception that still reaches this point is an analyzer
209
+ # bug and MUST propagate rather than silently dropping the alias, matching
210
+ # RbsLoader#each_known_class_name's fail-soft contract. It propagates to the seam in
211
+ # `Environment#hkt_registry` (issue #784), which records it and degrades to the pre-scan
212
+ # registry so it surfaces once per run instead of reaching every file's analysis.
213
+ translator = HktSugarTranslator.new(uri: uri, params_set: params_set)
202
214
  body_tree = translator.translate(decl.type)
203
215
 
204
216
  next unless translator.recursive