rigortype 0.2.9 → 0.3.1

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 (222) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/string_scanner.rbs +6 -5
  15. data/docs/handbook/01-getting-started.md +22 -34
  16. data/docs/handbook/03-narrowing.md +2 -1
  17. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  18. data/docs/handbook/06-classes.md +19 -10
  19. data/docs/handbook/07-rbs-and-extended.md +76 -101
  20. data/docs/handbook/08-understanding-errors.md +114 -246
  21. data/docs/handbook/09-plugins.md +54 -144
  22. data/docs/handbook/11-sig-gen.md +11 -1
  23. data/docs/handbook/README.md +5 -3
  24. data/docs/handbook/appendix-liskov.md +4 -2
  25. data/docs/handbook/appendix-mypy.md +4 -3
  26. data/docs/handbook/appendix-phpstan.md +14 -7
  27. data/docs/handbook/appendix-steep.md +4 -2
  28. data/docs/handbook/appendix-type-theory.md +3 -1
  29. data/docs/install.md +14 -6
  30. data/docs/manual/01-installation.md +98 -4
  31. data/docs/manual/02-cli-reference.md +81 -12
  32. data/docs/manual/03-configuration.md +34 -2
  33. data/docs/manual/04-diagnostics.md +59 -4
  34. data/docs/manual/06-baseline.md +35 -1
  35. data/docs/manual/07-plugins.md +4 -4
  36. data/docs/manual/08-skills.md +6 -1
  37. data/docs/manual/09-editor-integration.md +7 -6
  38. data/docs/manual/11-ci.md +51 -0
  39. data/docs/manual/12-caching.md +65 -0
  40. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  41. data/docs/manual/plugins/rigor-devise.md +4 -2
  42. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  43. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  45. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  46. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  47. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  48. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  49. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  50. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  51. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  52. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  53. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +112 -0
  54. data/lib/rigor/analysis/check_rules.rb +581 -98
  55. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  56. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  57. data/lib/rigor/analysis/incremental_session.rb +456 -51
  58. data/lib/rigor/analysis/path_expansion.rb +42 -0
  59. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  60. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  61. data/lib/rigor/analysis/run_cache_key.rb +78 -0
  62. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  63. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  64. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  65. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  66. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  67. data/lib/rigor/analysis/runner.rb +344 -68
  68. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  69. data/lib/rigor/analysis/worker_session.rb +30 -2
  70. data/lib/rigor/bleeding_edge.rb +37 -8
  71. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  72. data/lib/rigor/cache/descriptor.rb +88 -28
  73. data/lib/rigor/cache/file_digest.rb +158 -0
  74. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  75. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  76. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  77. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  78. data/lib/rigor/cache/store.rb +132 -27
  79. data/lib/rigor/ci_detector.rb +90 -0
  80. data/lib/rigor/cli/annotate_command.rb +15 -9
  81. data/lib/rigor/cli/baseline_command.rb +4 -1
  82. data/lib/rigor/cli/check_command.rb +223 -32
  83. data/lib/rigor/cli/check_invocation.rb +84 -0
  84. data/lib/rigor/cli/coverage_command.rb +6 -1
  85. data/lib/rigor/cli/docs_command.rb +0 -30
  86. data/lib/rigor/cli/doctor_command.rb +169 -31
  87. data/lib/rigor/cli/lsp_command.rb +5 -0
  88. data/lib/rigor/cli/mcp_command.rb +5 -0
  89. data/lib/rigor/cli/plugins_command.rb +36 -6
  90. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  91. data/lib/rigor/cli/probe_environment.rb +85 -0
  92. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  93. data/lib/rigor/cli/skill_command.rb +22 -33
  94. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  95. data/lib/rigor/cli/skill_describe.rb +75 -9
  96. data/lib/rigor/cli/trace_command.rb +5 -6
  97. data/lib/rigor/cli/type_of_command.rb +9 -8
  98. data/lib/rigor/cli/type_scan_command.rb +8 -8
  99. data/lib/rigor/cli.rb +15 -4
  100. data/lib/rigor/config_audit.rb +48 -2
  101. data/lib/rigor/configuration/severity_profile.rb +31 -0
  102. data/lib/rigor/configuration.rb +156 -8
  103. data/lib/rigor/environment/default_libraries.rb +36 -0
  104. data/lib/rigor/environment/rbs_loader.rb +164 -5
  105. data/lib/rigor/environment.rb +29 -28
  106. data/lib/rigor/inference/budget_trace.rb +77 -2
  107. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  108. data/lib/rigor/inference/def_handle.rb +23 -0
  109. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  110. data/lib/rigor/inference/def_return_typer.rb +2 -1
  111. data/lib/rigor/inference/expression_typer.rb +361 -113
  112. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  113. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  114. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  115. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  116. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +17 -1
  117. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  118. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  119. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  120. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  121. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  122. data/lib/rigor/inference/mutation_widening.rb +42 -8
  123. data/lib/rigor/inference/narrowing.rb +127 -17
  124. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  125. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  126. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  127. data/lib/rigor/inference/scope_indexer.rb +451 -36
  128. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  129. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  130. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  131. data/lib/rigor/inference/void_origin.rb +25 -0
  132. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  133. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  134. data/lib/rigor/language_server/buffer_table.rb +46 -6
  135. data/lib/rigor/language_server/completion_provider.rb +2 -1
  136. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
  137. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  138. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  139. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  140. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  141. data/lib/rigor/language_server/server.rb +19 -9
  142. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  143. data/lib/rigor/language_server.rb +1 -0
  144. data/lib/rigor/plugin/base.rb +38 -24
  145. data/lib/rigor/plugin/inflector.rb +12 -3
  146. data/lib/rigor/plugin/io_boundary.rb +5 -1
  147. data/lib/rigor/plugin/isolation.rb +81 -11
  148. data/lib/rigor/plugin/load_error.rb +10 -1
  149. data/lib/rigor/plugin/loader.rb +96 -14
  150. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  151. data/lib/rigor/plugin/registry.rb +32 -23
  152. data/lib/rigor/plugin.rb +26 -0
  153. data/lib/rigor/protection/mutator.rb +3 -2
  154. data/lib/rigor/reflection.rb +64 -0
  155. data/lib/rigor/runtime/jit.rb +128 -0
  156. data/lib/rigor/scope/discovery_index.rb +12 -2
  157. data/lib/rigor/scope.rb +122 -28
  158. data/lib/rigor/sig_gen/classification.rb +5 -1
  159. data/lib/rigor/sig_gen/generator.rb +38 -6
  160. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  161. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  162. data/lib/rigor/sig_gen/renderer.rb +10 -0
  163. data/lib/rigor/sig_gen/write_result.rb +9 -4
  164. data/lib/rigor/sig_gen/writer.rb +207 -49
  165. data/lib/rigor/source/node_children.rb +116 -0
  166. data/lib/rigor/source/node_locator.rb +3 -1
  167. data/lib/rigor/source/node_walker.rb +4 -2
  168. data/lib/rigor/source.rb +1 -0
  169. data/lib/rigor/type/difference.rb +28 -24
  170. data/lib/rigor/type/hash_shape.rb +34 -10
  171. data/lib/rigor/version.rb +1 -1
  172. data/lib/rigortype.rb +24 -0
  173. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  174. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  175. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  176. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  177. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  178. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  179. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  180. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  181. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  182. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  183. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  184. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  185. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  186. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  187. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  188. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  189. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  190. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  191. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  192. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  193. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  194. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  195. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  196. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  197. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  198. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  202. data/sig/prism_node_children.rbs +9 -0
  203. data/sig/rigor/cache.rbs +6 -0
  204. data/sig/rigor/inference/void_origin.rbs +18 -0
  205. data/sig/rigor/inference.rbs +7 -0
  206. data/sig/rigor/plugin/base.rbs +6 -6
  207. data/sig/rigor/reflection.rbs +1 -0
  208. data/sig/rigor/scope.rbs +12 -1
  209. data/skills/rigor-ci-setup/SKILL.md +10 -0
  210. data/skills/rigor-editor-setup/SKILL.md +11 -0
  211. data/skills/rigor-next-steps/SKILL.md +23 -2
  212. data/skills/rigor-plugin-author/SKILL.md +3 -3
  213. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  214. data/skills/rigor-plugin-review/SKILL.md +2 -2
  215. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  216. data/skills/rigor-project-init/SKILL.md +29 -3
  217. data/skills/rigor-project-init/references/01-detect.md +1 -1
  218. data/skills/rigor-project-init/references/02-configure.md +39 -5
  219. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  220. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  221. metadata +48 -26
  222. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -93,6 +93,11 @@ module Rigor
93
93
  # Returns the type of the named constant. Joins in-source constants (recorded by
94
94
  # `ScopeIndexer`) and RBS-side constants. In-source wins on collision because the user's
95
95
  # source is the authoritative declaration.
96
+ #
97
+ # This is the flat lookup keyed on the literal `constant_name`. Callers that need Ruby's
98
+ # lexical constant resolution (walking the enclosing class path, and folding in registry
99
+ # classes / source-discovered classes as `Singleton` / class types) use
100
+ # {.resolve_constant_type} instead.
96
101
  def constant_type_for(constant_name, scope: Scope.empty)
97
102
  key = constant_name.to_s
98
103
  in_source = scope.in_source_constants[key]
@@ -101,6 +106,65 @@ module Rigor
101
106
  scope.environment.constant_for_name(constant_name)
102
107
  end
103
108
 
109
+ # Resolves a constant *reference* to its type through Ruby's lexical constant lookup: the
110
+ # most-qualified candidate first (the enclosing class path joined to `name`), then
111
+ # progressively less-qualified, then the bare `name`. Each candidate consults, in order,
112
+ # the class registry (yielding a `Singleton[C]`), source-discovered classes, in-source
113
+ # value constants, and finally RBS-side constants — in-source value constants winning over
114
+ # RBS because the user's source is authoritative for its own constants. Returns the matched
115
+ # `Rigor::Type`, or nil when no source knows the constant.
116
+ #
117
+ # This is the shared owner of the lexical-constant resolution: `Inference::ExpressionTyper`
118
+ # reads it to type a constant read, and `Inference::Narrowing` reads it to recognise a
119
+ # value-pinned `Constant[Regexp]` match-predicate operand.
120
+ def resolve_constant_type(name, scope: Scope.empty)
121
+ env = scope.environment
122
+ discovered = scope.discovered_classes
123
+ in_source = scope.in_source_constants
124
+ lexical_constant_candidates(name, scope: scope).each do |candidate|
125
+ singleton = env.singleton_for_name(candidate)
126
+ return singleton if singleton
127
+
128
+ in_source_class = discovered[candidate]
129
+ return in_source_class if in_source_class
130
+
131
+ # In-source value-bearing constants take precedence over RBS constant decls because
132
+ # user code is the authoritative source for its own constants.
133
+ in_source_value = in_source[candidate]
134
+ return in_source_value if in_source_value
135
+
136
+ value = env.constant_for_name(candidate)
137
+ return value if value
138
+ end
139
+ nil
140
+ end
141
+
142
+ # The candidate qualified names to try, in Ruby's lexical order: most-qualified first (the
143
+ # enclosing class path joined to `name`), then progressively less-qualified, then the bare
144
+ # `name`. A top-level scope (no `self_type`) yields only `[name]`.
145
+ def lexical_constant_candidates(name, scope: Scope.empty)
146
+ prefix = enclosing_class_path(scope)
147
+ candidates = []
148
+ while prefix && !prefix.empty?
149
+ candidates << "#{prefix}::#{name}"
150
+ idx = prefix.rindex("::")
151
+ prefix = idx ? prefix[0, idx] : nil
152
+ end
153
+ candidates << name
154
+ candidates
155
+ end
156
+ private_class_method :lexical_constant_candidates
157
+
158
+ # Pulls the enclosing qualified class name out of `scope.self_type` when one is set.
159
+ # `Nominal[T]` and `Singleton[T]` both expose `class_name`. Returns nil at the top level.
160
+ def enclosing_class_path(scope)
161
+ st = scope.self_type
162
+ case st
163
+ when Type::Nominal, Type::Singleton then st.class_name
164
+ end
165
+ end
166
+ private_class_method :enclosing_class_path
167
+
104
168
  # Returns the RBS `RBS::Definition::Method` for the instance method, or nil when the
105
169
  # class or method is not in RBS. The source-side discovered-method facts are reachable
106
170
  # through {.discovered_method?}; a future slice will unify the two under a
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # Process-level runtime concerns that sit beside the analyzer rather than
5
+ # inside it. Currently just deferred YJIT enablement.
6
+ module Runtime
7
+ # Deferred YJIT enablement for the long-running analysis commands.
8
+ #
9
+ # Ruby 3.3+ ships YJIT but leaves it off unless the process opts in
10
+ # (`--yjit`, `RUBY_YJIT_ENABLE=1`, or `RubyVM::YJIT.enable`). Enabling it
11
+ # unconditionally at boot is a net LOSS on short `rigor check` / `coverage`
12
+ # runs: the JIT compile cost is paid up front, but a run that finishes in a
13
+ # couple of seconds ends before it amortizes. Measured cold A/B (2026-07-13,
14
+ # Ruby 4.0.5): mastodon `app/models` 4.0s -> 5.0s (0.80x), kramdown `lib`
15
+ # 1.7s -> 2.55s (0.66x), dependabot 1.6s -> 2.34s (0.68x). Long runs win big
16
+ # in the same measurement: mastodon `app`+`lib` 25.2s -> 14.9s (1.69x),
17
+ # redmine `app/models` 8.6s -> 5.5s (1.56x). Allocations and diagnostics are
18
+ # byte-identical under YJIT — the tradeoff is purely wall-time.
19
+ #
20
+ # The resolution is to enable YJIT only once a run has proven long enough to
21
+ # amortize the compile cost. {enable_after} spawns one daemon thread that
22
+ # sleeps the deadline then enables: a run that finishes before the deadline
23
+ # never pays JIT compile (the sub-deadline penalty zone is avoided by
24
+ # construction), while a long run enables mid-flight and JITs the remaining
25
+ # — dominant — work. Long-lived servers (`rigor lsp` / `rigor mcp`) instead
26
+ # enable at boot via {enable_now}, since they always run long.
27
+ #
28
+ # Opt-out: `RIGOR_DISABLE_YJIT=1` makes every entry point a no-op (the pin
29
+ # the byte-identical-diagnostics gate uses). If YJIT is already enabled (the
30
+ # user passed `--yjit` / `RUBY_YJIT_ENABLE=1`) or is unavailable (a build
31
+ # without YJIT, or a non-MRI Ruby), both entry points are no-ops too.
32
+ module Jit
33
+ # Opt-out switch: `RIGOR_DISABLE_YJIT=1` disables both entry points.
34
+ DISABLE_ENV = "RIGOR_DISABLE_YJIT"
35
+
36
+ # Advanced / calibration override of the {enable_after} deadline, in
37
+ # float seconds. Unset (or unparseable / negative) falls back to
38
+ # {DEFAULT_DEADLINE_SECONDS}. Exposed so the deadline can be swept during
39
+ # calibration and tuned per-project without an engine change.
40
+ DEADLINE_ENV = "RIGOR_YJIT_DEADLINE"
41
+
42
+ # The amortization deadline for {enable_after}, in seconds.
43
+ #
44
+ # Calibrated by interleaved cold A/B measurement on 2026-07-13 (Ruby
45
+ # 4.0.5, arm64-darwin25). Enabling YJIT mid-run costs a ~1s compile burst,
46
+ # so a run whose length is only ~0-1s longer than the deadline pays the
47
+ # burst without time to amortize and LOSES (measured: `app/models` at a
48
+ # 3.0s deadline regressed 4.14s -> 4.68s). The short-run cases cluster at
49
+ # <=1.7s (kramdown / dependabot) and ~4.0-4.9s (mastodon `app/models` ~4.1s,
50
+ # mail ~4.9s), with the next case at redmine ~7s. 5.0s parks that danger
51
+ # zone in the empty 5-6s gap: every case <=4.9s finishes before the
52
+ # deadline fires (guaranteed no-YJIT parity — the sleeping thread is killed
53
+ # at exit), while runs long enough to pay off (>=~15s: gitlab, mastodon
54
+ # `app`+`lib`) enable mid-flight and JIT their dominant tail — the mastodon
55
+ # `app`+`lib` win is preserved ~100% (25.4s -> 15.1s, vs 15.1s always-on).
56
+ DEFAULT_DEADLINE_SECONDS = 5.0
57
+
58
+ module_function
59
+
60
+ # Enables YJIT now, unless opted out, already enabled, or unavailable.
61
+ #
62
+ # An imperative command, not a query — the boolean reports whether this
63
+ # call is what enabled YJIT (so the `?`-suffix predicate convention does
64
+ # not apply; hence the Naming/PredicateMethod exemption).
65
+ #
66
+ # @return [Boolean] true if this call enabled YJIT; false if it was a
67
+ # no-op (unavailable / opted out / already enabled).
68
+ def enable_now # rubocop:disable Naming/PredicateMethod
69
+ return false unless available?
70
+ return false if disabled?
71
+ return false if RubyVM::YJIT.enabled?
72
+
73
+ RubyVM::YJIT.enable
74
+ true
75
+ end
76
+
77
+ # Spawns one background thread that sleeps `seconds` then calls
78
+ # {enable_now}. The thread is a daemon in effect — Ruby kills all live
79
+ # threads when the main thread exits — so a run that finishes before the
80
+ # deadline simply never enables YJIT, which is the point.
81
+ #
82
+ # Returns nil (and spawns no thread) when the feature is already a no-op
83
+ # up front (unavailable / opted out / already enabled), so callers never
84
+ # pay for a thread that could only no-op.
85
+ #
86
+ # @param seconds [Numeric] the amortization deadline.
87
+ # @return [Thread, nil] the deadline thread, or nil when no-op up front.
88
+ def enable_after(seconds)
89
+ return nil unless available?
90
+ return nil if disabled?
91
+ return nil if RubyVM::YJIT.enabled?
92
+
93
+ thread = Thread.new do
94
+ sleep(seconds)
95
+ enable_now
96
+ end
97
+ # A background perf optimization must never spill a backtrace onto the
98
+ # run's stderr; on any unexpected failure, stay silent and leave YJIT
99
+ # off. The deadline sleep gives ample time to set this before the body
100
+ # could raise.
101
+ thread.report_on_exception = false
102
+ thread
103
+ end
104
+
105
+ # The resolved {enable_after} deadline: {DEADLINE_ENV} when it parses to a
106
+ # non-negative float, else {DEFAULT_DEADLINE_SECONDS}.
107
+ #
108
+ # @return [Numeric]
109
+ def deadline_seconds
110
+ raw = ENV.fetch(DEADLINE_ENV, nil)
111
+ return DEFAULT_DEADLINE_SECONDS if raw.nil? || raw.empty?
112
+
113
+ value = Float(raw, exception: false)
114
+ value && value >= 0 ? value : DEFAULT_DEADLINE_SECONDS
115
+ end
116
+
117
+ # @return [Boolean] whether this Ruby exposes `RubyVM::YJIT.enable`.
118
+ def available?
119
+ defined?(RubyVM::YJIT.enable) ? true : false
120
+ end
121
+
122
+ # @return [Boolean] whether the opt-out env switch is set.
123
+ def disabled?
124
+ ENV[DISABLE_ENV] == "1"
125
+ end
126
+ end
127
+ end
128
+ end
@@ -21,13 +21,15 @@ module Rigor
21
21
  :discovered_def_nodes,
22
22
  :discovered_singleton_def_nodes,
23
23
  :discovered_def_sources,
24
+ :discovered_singleton_def_sources,
24
25
  :discovered_method_visibilities,
25
26
  :discovered_superclasses,
26
27
  :discovered_includes,
27
28
  :discovered_class_sources,
28
29
  :data_member_layouts,
29
30
  :struct_member_layouts,
30
- :param_inferred_types
31
+ :param_inferred_types,
32
+ :run_generation
31
33
  )
32
34
 
33
35
  class DiscoveryIndex
@@ -48,6 +50,7 @@ module Rigor
48
50
  discovered_def_nodes: EMPTY_TABLE,
49
51
  discovered_singleton_def_nodes: EMPTY_TABLE,
50
52
  discovered_def_sources: EMPTY_TABLE,
53
+ discovered_singleton_def_sources: EMPTY_TABLE,
51
54
  discovered_method_visibilities: EMPTY_TABLE,
52
55
  discovered_superclasses: EMPTY_TABLE,
53
56
  discovered_includes: EMPTY_TABLE,
@@ -60,7 +63,14 @@ module Rigor
60
63
  # `{param_name(Symbol) => Rigor::Type}` map of the union of resolved call-site argument types. Empty on
61
64
  # every normal run; only the `coverage --protection` collection pass populates it today, so a `check` run
62
65
  # leaves it empty and seeds nothing (byte-identical).
63
- param_inferred_types: EMPTY_TABLE
66
+ param_inferred_types: EMPTY_TABLE,
67
+ # ADR-84 WD2 — the run-scope identity token `Analysis::Runner#run_analysis` mints per run (a frozen
68
+ # bare Object) and seeds through `project_scope_seed_tables`. The user-method return memo keys its
69
+ # bucket on this token's identity so hits cross consumer-file boundaries within one run but never
70
+ # cross a run boundary (LSP / ADR-62 warm-loop re-runs land in a fresh bucket). Nil on scopes that
71
+ # never see the runner seed (single-file probes, `run_source` before the seed applies): the memo
72
+ # falls back to today's per-file `discovered_def_nodes` identity for those.
73
+ run_generation: nil
64
74
  )
65
75
  end
66
76
  end
data/lib/rigor/scope.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "analysis/dependency_recorder"
8
8
  require_relative "inference/expression_typer"
9
9
  require_relative "inference/flow_tracer"
10
10
  require_relative "inference/statement_evaluator"
11
+ require_relative "inference/def_node_resolver"
11
12
 
12
13
  module Rigor
13
14
  # Immutable analyzer scope: holds local-variable bindings and a reference to the surrounding Environment. State
@@ -22,7 +23,8 @@ module Rigor
22
23
  :indexed_narrowings, :method_chain_narrowings,
23
24
  :declaration_sourced,
24
25
  :source_path, :discovery, :struct_fold_safe_locals,
25
- :dynamic_origins, :local_origins, :ivar_origins
26
+ :dynamic_origins, :local_origins, :ivar_origins,
27
+ :void_origins
26
28
 
27
29
  # ADR-53 Track A — the seed-time discovery tables live on the {DiscoveryIndex} the scope carries by a single
28
30
  # reference; the per-table readers stay on Scope so engine call sites and plugins are unaffected by the
@@ -41,6 +43,7 @@ module Rigor
41
43
  def discovered_def_nodes = @discovery.discovered_def_nodes
42
44
  def discovered_singleton_def_nodes = @discovery.discovered_singleton_def_nodes
43
45
  def discovered_def_sources = @discovery.discovered_def_sources
46
+ def discovered_singleton_def_sources = @discovery.discovered_singleton_def_sources
44
47
  def discovered_method_visibilities = @discovery.discovered_method_visibilities
45
48
  def discovered_superclasses = @discovery.discovered_superclasses
46
49
  def discovered_includes = @discovery.discovered_includes
@@ -52,6 +55,9 @@ module Rigor
52
55
  # call-site argument types (precision-additive; an RBS-declared parameter always wins). Empty unless a
53
56
  # collection pass seeded it.
54
57
  def param_inferred_types = @discovery.param_inferred_types
58
+ # ADR-84 WD2 — the per-run identity token the user-method return memo buckets on (nil outside runner-seeded
59
+ # scopes; the memo then falls back to the per-file `discovered_def_nodes` identity).
60
+ def run_generation = @discovery.run_generation
55
61
 
56
62
  # Narrowing key for an indexed read `receiver[key]` where both the receiver and the key are stable enough to
57
63
  # address. The value of the map at this key is the narrowed type the next read at the same address MUST
@@ -117,6 +123,16 @@ module Rigor
117
123
  self
118
124
  end
119
125
 
126
+ # ADR-100 WD3 — records that the value introduced at `node` (a call node) is a `top` recovered from an
127
+ # author-declared `-> void` return, keyed by the origin site (`origin`, an {Inference::VoidOrigin}). The
128
+ # value-context check rule `static.value-use.void` consumes this table. Mirrors {#record_dynamic_origin}
129
+ # exactly: identity-keyed advisory metadata, mutated in place on the shared table (threaded by reference
130
+ # through `#join` / `#rebuild`), excluded from `==` / `hash`, so it never forks a flow-dedup or cache key.
131
+ def record_void_origin(node, origin)
132
+ @void_origins[node] = origin
133
+ self
134
+ end
135
+
120
136
  def initialize(
121
137
  environment:, locals:,
122
138
  fact_store: Analysis::FactStore.empty,
@@ -132,7 +148,8 @@ module Rigor
132
148
  struct_fold_safe_locals: EMPTY_FOLD_SAFE,
133
149
  dynamic_origins: {}.compare_by_identity,
134
150
  local_origins: EMPTY_ORIGINS,
135
- ivar_origins: EMPTY_ORIGINS
151
+ ivar_origins: EMPTY_ORIGINS,
152
+ void_origins: {}.compare_by_identity
136
153
  )
137
154
  @environment = environment
138
155
  @locals = locals
@@ -150,6 +167,7 @@ module Rigor
150
167
  @dynamic_origins = dynamic_origins
151
168
  @local_origins = local_origins
152
169
  @ivar_origins = ivar_origins
170
+ @void_origins = void_origins
153
171
  freeze
154
172
  end
155
173
 
@@ -294,6 +312,39 @@ module Rigor
294
312
  @declaration_sourced.include?([kind.to_sym, name.to_sym])
295
313
  end
296
314
 
315
+ # ADR-67 WD6b — stamp the "inferred, not declared" provenance mark on a parameter local seeded from the
316
+ # call-site parameter-inference table ({Inference::ParameterInferenceCollector}). Rides the ADR-58 WD1
317
+ # declaration-sourced side-mark machinery under a distinct `:inferred_param` kind (never a carrier field,
318
+ # so the displayed type is unchanged) so the negative in-body rules can decline on a receiver / argument
319
+ # whose type is an open-call-site *lower bound* — firing against a lower bound is a false positive by
320
+ # construction (the ADR-67 WD1 reasoning at the parameter boundary, carried one hop into the body). The
321
+ # distinct kind keeps the inferred-param sites separable from ADR-58's ivar-copy `:local` mark, which a
322
+ # later un-guarding slice (WD6b) needs. `with_local` drops it on any flow-live rewrite of the local
323
+ # (`drop_local_declaration_marks`), so only the pristine parameter binding carries it.
324
+ def with_inferred_param_mark(name)
325
+ rebuild(declaration_sourced: add_declaration_sourced(:inferred_param, name))
326
+ end
327
+
328
+ # ADR-67 WD6b — explicitly clear the inferred-parameter taint on `name`. The mark is deliberately STICKY:
329
+ # `with_local` (used by both narrowing and reassignment) does NOT drop it, so it survives the narrowing /
330
+ # join transitions between a lower-bound value's definition and its use (`v = param[i]-1; if 0<=v and v<n`
331
+ # — the `and` narrows `v` between the two comparisons). It is cleared only at a genuine source-level local
332
+ # write whose RHS does not derive from an inferred parameter ({StatementEvaluator#eval_local_write}), so a
333
+ # local rebound to an independent value stops being treated as a lower bound. Over-retention (a mark that
334
+ # outlives a rebind the write-path did not catch) only ever suppresses a diagnostic — the FP-safe
335
+ # direction — so stickiness is the conservative choice. Zero-alloc when no mark is present.
336
+ def without_inferred_param_mark(name)
337
+ dropped = drop_declaration_sourced_for(:inferred_param, name)
338
+ dropped.equal?(@declaration_sourced) ? self : rebuild(declaration_sourced: dropped)
339
+ end
340
+
341
+ # ADR-67 WD6b — true when `name`'s local binding is a pristine inferred parameter (the call-site union
342
+ # seeded at method entry, untouched by a flow-live write). The guard predicate the negative in-body rules
343
+ # consult.
344
+ def inferred_param?(name)
345
+ @declaration_sourced.include?([:inferred_param, name.to_sym])
346
+ end
347
+
297
348
  def with_cvar(name, type)
298
349
  rebuild(cvars: @cvars.merge(name.to_sym => type).freeze)
299
350
  end
@@ -366,9 +417,12 @@ module Rigor
366
417
  # inter-procedural return-type inference when the receiver class is user-defined and has no RBS sig.
367
418
  def user_def_for(class_name, method_name)
368
419
  table = @discovery.discovered_def_nodes[class_name.to_s]
369
- node = table && table[method_name.to_sym]
370
- record_cross_file_method(class_name, method_name, node) if Analysis::DependencyRecorder.active?
371
- node
420
+ # ADR-85 WD3 the value is either a live `Prism::DefNode` (cold / re-walked file) or a `DefHandle`
421
+ # (unchanged file, bundle-rebuilt index). Dependency recording keys on the table's PRESENCE (both are
422
+ # truthy), so it is sound regardless of resolution; only the returned node is resolved lazily.
423
+ entry = table && table[method_name.to_sym]
424
+ record_cross_file_method(class_name, method_name, entry) if Analysis::DependencyRecorder.active?
425
+ Inference::DefNodeResolver.resolve(entry)
372
426
  end
373
427
 
374
428
  # Module-singleton call resolution (ADR-57 follow-up) — companion of {#user_def_for} for SINGLETON-side defs
@@ -379,25 +433,28 @@ module Rigor
379
433
  # the same cross-file dependency edge as the instance path (ADR-46).
380
434
  def singleton_def_for(class_name, method_name)
381
435
  table = @discovery.discovered_singleton_def_nodes[class_name.to_s]
382
- node = table && table[method_name.to_sym]
383
- record_cross_file_method(class_name, method_name, node) if Analysis::DependencyRecorder.active?
384
- node
436
+ entry = table && table[method_name.to_sym] # live node or DefHandle (ADR-85 WD3)
437
+ record_cross_file_method(class_name, method_name, entry, singleton: true) if Analysis::DependencyRecorder.active?
438
+ Inference::DefNodeResolver.resolve(entry)
385
439
  end
386
440
 
387
441
  # ADR-46 slice 1 — note the cross-file dependency this resolution creates: the file defining
388
442
  # `class_name#method_name` (the consumer's analysis reads its body via `infer_user_method_return`), or, when
389
443
  # unresolved, a negative edge so a later definition re-checks the consumer. Gated on the recorder being
390
- # active — no-op on a normal run.
391
- def record_cross_file_method(class_name, method_name, node)
444
+ # active — no-op on a normal run. `singleton:` selects the singleton-side source table + a `"Class.method"`
445
+ # symbol key (vs the instance `"Class#method"`), so a class/singleton-method body edit produces a changed
446
+ # symbol pair and scopes to the method's call sites the same way an instance-method edit does — the source
447
+ # site is read from `discovered_singleton_def_sources`, the mirror the ScopeIndexer now records (ADR-46
448
+ # slice 4 singleton extension). Both keys share the format `Runner#symbol_fingerprints` emits.
449
+ def record_cross_file_method(class_name, method_name, node, singleton: false)
450
+ symbol = "#{class_name}#{singleton ? '.' : '#'}#{method_name}"
392
451
  if node
393
452
  # ADR-46 slice 4 — pass the symbol so the recorder tracks this as a method-call (symbol-granularity) edge
394
453
  # rather than a file-level edge.
395
- Analysis::DependencyRecorder.read_site(
396
- @discovery.discovered_def_sources.dig(class_name.to_s, method_name.to_sym),
397
- "#{class_name}##{method_name}"
398
- )
454
+ source_table = singleton ? @discovery.discovered_singleton_def_sources : @discovery.discovered_def_sources
455
+ Analysis::DependencyRecorder.read_site(source_table.dig(class_name.to_s, method_name.to_sym), symbol)
399
456
  else
400
- Analysis::DependencyRecorder.read_missing(:method, "#{class_name}##{method_name}")
457
+ Analysis::DependencyRecorder.read_missing(:method, symbol)
401
458
  end
402
459
  end
403
460
  private :record_cross_file_method
@@ -408,9 +465,9 @@ module Rigor
408
465
  # implementation detail and go through this accessor.
409
466
  def top_level_def_for(method_name)
410
467
  table = @discovery.discovered_def_nodes[Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY]
411
- node = table && table[method_name.to_sym]
412
- record_cross_file_toplevel(method_name, node) if Analysis::DependencyRecorder.active?
413
- node
468
+ entry = table && table[method_name.to_sym] # live node or DefHandle (ADR-85 WD3)
469
+ record_cross_file_toplevel(method_name, entry) if Analysis::DependencyRecorder.active?
470
+ Inference::DefNodeResolver.resolve(entry)
414
471
  end
415
472
 
416
473
  # ADR-46 slice 3 — a top-level (`def helper` outside any class) call has NO class ancestry to walk, so unlike
@@ -440,9 +497,16 @@ module Rigor
440
497
  # can point at `pre_eval:` (ADR-17) instead of reading as a bare unresolved call.
441
498
  def user_def_site_for(class_name, method_name)
442
499
  table = @discovery.discovered_def_sources[class_name.to_s]
443
- return nil unless table
444
-
445
- table[method_name.to_sym]
500
+ site = table && table[method_name.to_sym]
501
+ # ADR-88 WD3 — record the SAME instance-side cross-file method edge {#user_def_for} records at :378, so a
502
+ # move / body-edit of `class_name#method_name`'s definition re-checks the consumer that named the
503
+ # defining file. `CheckRules#undefined_method_diagnostic` reads this to set `project_definition_site`
504
+ # (`"path:line"`) on a `call.undefined-method` for a project monkey-patch; without the edge, a line-shift
505
+ # in the defining file left the cached diagnostic pointing at a stale line (the ADR-46 symbol-granularity
506
+ # closure never re-checked the caller). Recording keys on the source-entry PRESENCE (truthy `site`),
507
+ # sound whether or not the caller also went through {#user_def_for}.
508
+ record_cross_file_method(class_name, method_name, site) if Analysis::DependencyRecorder.active?
509
+ site
446
510
  end
447
511
 
448
512
  # ADR-24 slice 2 — per-class table mapping a fully qualified user-class name to its superclass name AS WRITTEN
@@ -592,6 +656,14 @@ module Rigor
592
656
  Inference::ExpressionTyper.new(scope: self, tracer: tracer).type_of(node)
593
657
  end
594
658
 
659
+ # ADR-89 WD2 — the inferred return type of `def_node` called with `receiver` / `arg_types`, computed
660
+ # against THIS scope's discovery index (so cross-file dispatches in the body resolve). The incremental
661
+ # session re-drives a declaration-stable changed callee at each previously-observed call key to prove its
662
+ # return is unchanged before skipping its symbol dependents.
663
+ def user_method_return(def_node, receiver, arg_types)
664
+ Inference::ExpressionTyper.new(scope: self).return_type_for(def_node, receiver, arg_types)
665
+ end
666
+
595
667
  # Statement-level evaluation: returns the pair `[type, scope']` where `type` is what the node produces and
596
668
  # `scope'` is the scope observable after the node has run. The receiver scope is never mutated. See
597
669
  # {Rigor::Inference::StatementEvaluator} for the catalogue of nodes that thread scope; everything else defers
@@ -651,7 +723,8 @@ module Rigor
651
723
  struct_fold_safe_locals: @struct_fold_safe_locals,
652
724
  dynamic_origins: @dynamic_origins,
653
725
  local_origins: @local_origins,
654
- ivar_origins: @ivar_origins
726
+ ivar_origins: @ivar_origins,
727
+ void_origins: @void_origins
655
728
  )
656
729
  self.class.new(
657
730
  environment: environment, locals: locals,
@@ -665,7 +738,8 @@ module Rigor
665
738
  struct_fold_safe_locals: struct_fold_safe_locals,
666
739
  dynamic_origins: dynamic_origins,
667
740
  local_origins: local_origins,
668
- ivar_origins: ivar_origins
741
+ ivar_origins: ivar_origins,
742
+ void_origins: void_origins
669
743
  )
670
744
  end
671
745
 
@@ -702,7 +776,8 @@ module Rigor
702
776
  source_path: source_path,
703
777
  dynamic_origins: @dynamic_origins,
704
778
  local_origins: join_origins(@local_origins, other.local_origins),
705
- ivar_origins: join_origins(@ivar_origins, other.ivar_origins)
779
+ ivar_origins: join_origins(@ivar_origins, other.ivar_origins),
780
+ void_origins: @void_origins
706
781
  )
707
782
  end
708
783
 
@@ -724,11 +799,30 @@ module Rigor
724
799
  origins.key?(key) ? origins.reject { |k, _| k == key }.freeze : origins
725
800
  end
726
801
 
802
+ # Two kinds of mark share this Set with opposite join semantics:
803
+ #
804
+ # - ADR-58 WD1 declaration-sourced optionality (`:ivar` / `:local`) joins by **intersection** — a nil is
805
+ # assumed a cross-method invariant only when BOTH branches agree its optionality is declaration-sourced.
806
+ # - ADR-67 WD6b inferred-parameter taint (`:inferred_param`) joins by **union** — a local is a lower-bound
807
+ # value on the branch where it derives from an inferred parameter, so if EITHER branch taints it, a
808
+ # downstream use could observe the lower-bound value and firing on it is an FP (`x = param else x = 5;
809
+ # x.foo`). Intersecting would lose the taint at the merge and re-surface the false positive.
727
810
  def join_declaration_sourced(other)
728
- return @declaration_sourced if @declaration_sourced.equal?(other.declaration_sourced)
729
- return EMPTY_DECLARATION_SOURCED if @declaration_sourced.empty? || other.declaration_sourced.empty?
730
-
731
- (@declaration_sourced & other.declaration_sourced).freeze
811
+ mine = @declaration_sourced
812
+ theirs = other.declaration_sourced
813
+ return mine if mine.equal?(theirs)
814
+
815
+ inferred = Set.new
816
+ mine.each { |ref| inferred << ref if ref[0] == :inferred_param }
817
+ theirs.each { |ref| inferred << ref if ref[0] == :inferred_param }
818
+ intersected =
819
+ if mine.empty? || theirs.empty?
820
+ EMPTY_DECLARATION_SOURCED
821
+ else
822
+ mine.select { |ref| ref[0] != :inferred_param && theirs.include?(ref) }
823
+ end
824
+ merged = inferred.merge(intersected)
825
+ merged.empty? ? EMPTY_DECLARATION_SOURCED : merged.freeze
732
826
  end
733
827
 
734
828
  def indexed_key(receiver_kind, receiver_name, key)
@@ -25,7 +25,11 @@ module Rigor
25
25
  SKIP_DIAGNOSTIC_IDS = {
26
26
  complex_shape: "sig.skipped.complex-shape",
27
27
  user_authored: "sig.skipped.user-authored",
28
- untyped_return: "sig.skipped.untyped-return"
28
+ untyped_return: "sig.skipped.untyped-return",
29
+ # The generator rendered a line `rbs` itself rejects. Skipping the method keeps sig-gen useful on a
30
+ # project with one pathological def, where failing the command outright would deny the user every other
31
+ # signature; the count is reported so the defect is not silent. See {SigGen::RbsValidity}.
32
+ unrenderable_rbs: "sig.skipped.unrenderable-rbs"
29
33
  }.freeze
30
34
  end
31
35
  end
@@ -8,9 +8,11 @@ require_relative "../scope"
8
8
  require_relative "../reflection"
9
9
  require_relative "../type"
10
10
  require_relative "../source/literals"
11
+ require_relative "../source/node_children"
11
12
  require_relative "../inference/def_return_typer"
12
13
  require_relative "../inference/scope_indexer"
13
14
  require_relative "../inference/rbs_type_translator"
15
+ require_relative "rbs_validity"
14
16
 
15
17
  module Rigor
16
18
  module SigGen
@@ -33,6 +35,13 @@ module Rigor
33
35
  # strictly accepts the inferred one (acceptance check under the engine's current `:gradual` mode; ADR-14
34
36
  # reserves the eventual `:strict` mode).
35
37
  class Generator # rubocop:disable Metrics/ClassLength
38
+ # Methods the generator rendered into RBS that `rbs` itself rejects. Populated by {#build_candidate}; each
39
+ # one is a Rigor rendering DEFECT, not a property of the user's code, so the CLI reports them as such.
40
+ UnrenderableMethod = Data.define(:path, :class_name, :method_name, :rbs, :error)
41
+
42
+ # @return [Array<UnrenderableMethod>] empty on a healthy run; read after {#run}.
43
+ attr_reader :unrenderable
44
+
36
45
  # @param configuration [Rigor::Configuration]
37
46
  # @param paths [Array<String>] files / directories to scan.
38
47
  # @param observations [Hash{[String, Symbol] => Array<Array<Rigor::Type>>}]
@@ -52,6 +61,8 @@ module Rigor
52
61
  @module_function_methods = Set.new
53
62
  @class_shells = Set.new
54
63
  @class_superclasses = {}
64
+ # Whole-run, NOT per-file: a rendering defect is reported once at the end of the run.
65
+ @unrenderable = []
55
66
  end
56
67
 
57
68
  # Lifts legacy plain-`Array[Type]` observation entries into {ObservedCall} carriers. Specs from the
@@ -159,7 +170,7 @@ module Rigor
159
170
  return
160
171
  end
161
172
 
162
- node.compact_child_nodes.each do |child|
173
+ node.rigor_each_child do |child|
163
174
  walk_defs(child, prefix, in_singleton_class, module_function_active, out)
164
175
  end
165
176
  end
@@ -264,12 +275,33 @@ module Rigor
264
275
  # Wraps `MethodCandidate.new` so every candidate carries the per-file `@namespace_kinds` map AND the
265
276
  # `@class_shells` set — the Writer's nested-syntax emission consults both to pick `module` vs `class` for
266
277
  # each segment and to emit empty `Const = Data.define(...)` declarations.
267
- def build_candidate(**)
278
+ #
279
+ # It is also where every rendered line is PARSED before it can leave the generator (see
280
+ # {SigGen::RbsValidity}). This is the one construction point all candidates pass through, so guarding it
281
+ # covers every mode — `--print`, `--diff`, `--write`, and the MCP surface — rather than each emit site
282
+ # separately. A line `rbs` rejects demotes the candidate to `:skipped` instead of being emitted: a
283
+ # signature we cannot parse is worse than no signature, because it takes the whole FILE down with it
284
+ # (the consumer quarantines it, so every other type in that file vanishes too).
285
+ def build_candidate(**fields)
286
+ fields = demote_unrenderable(fields)
268
287
  MethodCandidate.new(
269
288
  namespace_kinds: @namespace_kinds,
270
289
  class_shells: @class_shells.to_a,
271
290
  class_superclasses: @class_superclasses,
272
- **
291
+ **fields
292
+ )
293
+ end
294
+
295
+ def demote_unrenderable(fields)
296
+ error = RbsValidity.method_line_error(fields[:rbs])
297
+ return fields if error.nil?
298
+
299
+ @unrenderable << UnrenderableMethod.new(
300
+ path: fields[:path], class_name: fields[:class_name],
301
+ method_name: fields[:method_name], rbs: fields[:rbs], error: error
302
+ )
303
+ fields.merge(
304
+ classification: Classification::SKIPPED, skip_reason: :unrenderable_rbs, rbs: nil
273
305
  )
274
306
  end
275
307
 
@@ -796,7 +828,7 @@ module Rigor
796
828
  collect_attr_call(node, prefix, in_singleton_class, ctx)
797
829
  end
798
830
 
799
- node.compact_child_nodes.each { |child| walk_attr_calls(child, prefix, in_singleton_class, ctx) }
831
+ node.rigor_each_child { |child| walk_attr_calls(child, prefix, in_singleton_class, ctx) }
800
832
  end
801
833
 
802
834
  def collect_attr_call(call_node, prefix, in_singleton_class, ctx)
@@ -872,7 +904,7 @@ module Rigor
872
904
  return
873
905
  end
874
906
 
875
- node.compact_child_nodes.each { |c| collect_init_ivar_obs(c, prefix, result) }
907
+ node.rigor_each_child { |c| collect_init_ivar_obs(c, prefix, result) }
876
908
  end
877
909
 
878
910
  # Derive { attr_name_sym => Type } for a single `def initialize` by matching `@ivar = param_name`
@@ -950,7 +982,7 @@ module Rigor
950
982
  node.is_a?(Prism::ClassNode) ||
951
983
  node.is_a?(Prism::ModuleNode)
952
984
 
953
- node.compact_child_nodes.each { |c| scan_ivar_param_assignments(c, param_names, result) }
985
+ node.rigor_each_child { |c| scan_ivar_param_assignments(c, param_names, result) }
954
986
  end
955
987
 
956
988
  def build_attr_candidates(call_name, class_name, attr_name, ivar_type, ctx)