rigortype 0.2.8 → 0.3.0

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 (207) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/core_overlay/csv.rbs +28 -0
  4. data/data/core_overlay/psych.rbs +22 -0
  5. data/docs/handbook/01-getting-started.md +9 -1
  6. data/docs/handbook/02-everyday-types.md +4 -1
  7. data/docs/handbook/03-narrowing.md +2 -1
  8. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  9. data/docs/handbook/06-classes.md +18 -9
  10. data/docs/handbook/08-understanding-errors.md +4 -3
  11. data/docs/handbook/11-sig-gen.md +11 -1
  12. data/docs/handbook/appendix-mypy.md +4 -3
  13. data/docs/install.md +14 -6
  14. data/docs/manual/01-installation.md +99 -4
  15. data/docs/manual/02-cli-reference.md +65 -19
  16. data/docs/manual/03-configuration.md +34 -2
  17. data/docs/manual/04-diagnostics.md +23 -0
  18. data/docs/manual/07-plugins.md +5 -5
  19. data/docs/manual/09-editor-integration.md +4 -4
  20. data/docs/manual/11-ci.md +51 -0
  21. data/docs/manual/12-caching.md +65 -0
  22. data/docs/manual/14-rails-quickstart.md +4 -2
  23. data/docs/manual/15-type-protection-coverage.md +21 -0
  24. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  25. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  26. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  27. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  28. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  29. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  30. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  31. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  32. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  33. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  34. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  35. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  36. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  37. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  38. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  39. data/lib/rigor/analysis/check_rules.rb +557 -95
  40. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  41. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  42. data/lib/rigor/analysis/incremental_session.rb +456 -51
  43. data/lib/rigor/analysis/path_expansion.rb +42 -0
  44. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  45. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  46. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  47. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  48. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  50. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  52. data/lib/rigor/analysis/runner.rb +343 -68
  53. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  54. data/lib/rigor/analysis/worker_session.rb +30 -2
  55. data/lib/rigor/bleeding_edge.rb +37 -8
  56. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  57. data/lib/rigor/cache/descriptor.rb +88 -28
  58. data/lib/rigor/cache/file_digest.rb +158 -0
  59. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  60. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  61. data/lib/rigor/cache/store.rb +207 -60
  62. data/lib/rigor/ci_detector.rb +90 -0
  63. data/lib/rigor/cli/annotate_command.rb +15 -9
  64. data/lib/rigor/cli/baseline_command.rb +4 -1
  65. data/lib/rigor/cli/check_command.rb +212 -27
  66. data/lib/rigor/cli/coverage_command.rb +56 -30
  67. data/lib/rigor/cli/diagnostic_formats.rb +4 -1
  68. data/lib/rigor/cli/docs_command.rb +0 -30
  69. data/lib/rigor/cli/doctor_command.rb +163 -23
  70. data/lib/rigor/cli/lsp_command.rb +5 -0
  71. data/lib/rigor/cli/mcp_command.rb +5 -0
  72. data/lib/rigor/cli/plugins_command.rb +36 -6
  73. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  74. data/lib/rigor/cli/probe_environment.rb +85 -0
  75. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  76. data/lib/rigor/cli/protection_report.rb +7 -1
  77. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  78. data/lib/rigor/cli/skill_command.rb +1 -32
  79. data/lib/rigor/cli/trace_command.rb +5 -6
  80. data/lib/rigor/cli/type_of_command.rb +9 -8
  81. data/lib/rigor/cli/type_scan_command.rb +8 -8
  82. data/lib/rigor/cli.rb +15 -4
  83. data/lib/rigor/config_audit.rb +48 -2
  84. data/lib/rigor/configuration/severity_profile.rb +31 -0
  85. data/lib/rigor/configuration.rb +156 -8
  86. data/lib/rigor/environment/default_libraries.rb +35 -0
  87. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  88. data/lib/rigor/environment/rbs_loader.rb +164 -5
  89. data/lib/rigor/environment.rb +71 -43
  90. data/lib/rigor/inference/budget_trace.rb +77 -2
  91. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  92. data/lib/rigor/inference/def_handle.rb +23 -0
  93. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  94. data/lib/rigor/inference/def_return_typer.rb +2 -1
  95. data/lib/rigor/inference/expression_typer.rb +379 -113
  96. data/lib/rigor/inference/fork_map.rb +87 -0
  97. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  98. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  99. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  100. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  101. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  102. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  103. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  104. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  105. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  106. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  107. data/lib/rigor/inference/mutation_widening.rb +42 -8
  108. data/lib/rigor/inference/narrowing.rb +245 -17
  109. data/lib/rigor/inference/parameter_inference_collector.rb +70 -10
  110. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  111. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  112. data/lib/rigor/inference/scope_indexer.rb +460 -49
  113. data/lib/rigor/inference/statement_evaluator.rb +111 -17
  114. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  115. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  116. data/lib/rigor/inference/void_origin.rb +25 -0
  117. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  118. data/lib/rigor/language_server/completion_provider.rb +2 -1
  119. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  120. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  121. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  122. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  123. data/lib/rigor/plugin/base.rb +9 -22
  124. data/lib/rigor/plugin/inflector.rb +12 -3
  125. data/lib/rigor/plugin/io_boundary.rb +5 -1
  126. data/lib/rigor/plugin/isolation.rb +81 -11
  127. data/lib/rigor/plugin/load_error.rb +10 -1
  128. data/lib/rigor/plugin/loader.rb +96 -14
  129. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  130. data/lib/rigor/plugin/registry.rb +32 -23
  131. data/lib/rigor/plugin.rb +26 -0
  132. data/lib/rigor/protection/mutator.rb +3 -2
  133. data/lib/rigor/reflection.rb +64 -0
  134. data/lib/rigor/runtime/jit.rb +128 -0
  135. data/lib/rigor/scope/discovery_index.rb +12 -2
  136. data/lib/rigor/scope.rb +122 -28
  137. data/lib/rigor/sig_gen/classification.rb +5 -1
  138. data/lib/rigor/sig_gen/generator.rb +38 -6
  139. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  140. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  141. data/lib/rigor/sig_gen/renderer.rb +10 -0
  142. data/lib/rigor/sig_gen/write_result.rb +9 -4
  143. data/lib/rigor/sig_gen/writer.rb +24 -2
  144. data/lib/rigor/source/node_children.rb +116 -0
  145. data/lib/rigor/source/node_locator.rb +3 -1
  146. data/lib/rigor/source/node_walker.rb +4 -2
  147. data/lib/rigor/source.rb +1 -0
  148. data/lib/rigor/type/difference.rb +28 -24
  149. data/lib/rigor/type/hash_shape.rb +34 -10
  150. data/lib/rigor/version.rb +1 -1
  151. data/lib/rigortype.rb +24 -0
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  154. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  158. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +13 -2
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +65 -2
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
  161. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  162. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  163. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
  164. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  165. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  166. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  167. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  169. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  170. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  171. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  172. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  173. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  174. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +191 -0
  175. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  176. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
  177. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +48 -15
  178. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
  179. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  180. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  181. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  182. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  184. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  185. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  186. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  187. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  188. data/sig/prism_node_children.rbs +9 -0
  189. data/sig/rigor/environment.rbs +1 -0
  190. data/sig/rigor/inference.rbs +7 -0
  191. data/sig/rigor/plugin/base.rbs +2 -3
  192. data/sig/rigor/reflection.rbs +1 -0
  193. data/sig/rigor/scope.rbs +12 -1
  194. data/skills/rigor-ci-setup/SKILL.md +10 -0
  195. data/skills/rigor-editor-setup/SKILL.md +11 -0
  196. data/skills/rigor-next-steps/SKILL.md +23 -2
  197. data/skills/rigor-plugin-author/SKILL.md +3 -3
  198. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  199. data/skills/rigor-plugin-review/SKILL.md +2 -2
  200. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  201. data/skills/rigor-project-init/SKILL.md +29 -3
  202. data/skills/rigor-project-init/references/01-detect.md +1 -1
  203. data/skills/rigor-project-init/references/02-configure.md +39 -5
  204. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  205. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  206. metadata +49 -24
  207. data/lib/rigor/cli/ci_detector.rb +0 -89
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)
@@ -6,6 +6,7 @@ require_relative "../environment"
6
6
  require_relative "../scope"
7
7
  require_relative "../type"
8
8
  require_relative "../source/literals"
9
+ require_relative "../source/node_children"
9
10
  require_relative "../inference/scope_indexer"
10
11
 
11
12
  module Rigor
@@ -126,7 +127,7 @@ module Rigor
126
127
  end
127
128
  end
128
129
 
129
- node.compact_child_nodes.each { |child| walk_class_decls(child, prefix, accumulator) }
130
+ node.rigor_each_child { |child| walk_class_decls(child, prefix, accumulator) }
130
131
  end
131
132
 
132
133
  def qualified_constant_path(constant_path)
@@ -146,7 +147,7 @@ module Rigor
146
147
  return unless node.is_a?(Prism::Node)
147
148
 
148
149
  record_call(node, scope_index, bindings, observations) if node.is_a?(Prism::CallNode)
149
- node.compact_child_nodes.each { |child| walk_calls(child, scope_index, bindings, observations) }
150
+ node.rigor_each_child { |child| walk_calls(child, scope_index, bindings, observations) }
150
151
  end
151
152
 
152
153
  def record_call(call_node, scope_index, bindings, observations)
@@ -244,7 +245,7 @@ module Rigor
244
245
  recognise_describe(node, bindings)
245
246
  recognise_subject_or_let(node, bindings, scope_index)
246
247
 
247
- node.compact_child_nodes.each { |child| walk_rspec_bindings(child, bindings, scope_index) }
248
+ node.rigor_each_child { |child| walk_rspec_bindings(child, bindings, scope_index) }
248
249
  end
249
250
 
250
251
  def recognise_describe(node, bindings)
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbs"
4
+
5
+ module Rigor
6
+ module SigGen
7
+ # Parses what sig-gen is about to emit, before it is emitted.
8
+ #
9
+ # Two rendering bugs have shipped RBS that `rbs` itself rejects — a non-identifier record key
10
+ # (`{ :"data-contrast" => T }`) and a `&block` constructor parameter rendered before the parens
11
+ # (`(**untyped, ?{ (?) -> void })`). Both were found downstream, as a poisoned `sig/` tree: the file is
12
+ # quarantined, its types vanish, and (for a project adopting `reject-unparseable-signatures`) the build
13
+ # fails. A generator that can poison its own consumer's build is worth guarding at the source.
14
+ #
15
+ # So this is not a check for ONE bug — it is the guard that turns the whole rendering-bug *class* from a
16
+ # silent bad artifact into a caught error naming the method that could not be rendered. It is cheap: the
17
+ # text is already in memory, and `RBS::Parser` is the same C parser the consumer will use, so it is exactly
18
+ # the oracle that matters (not an approximation of it).
19
+ module RbsValidity
20
+ # A rendered method line (`def foo: () -> String`) is a fragment, not a compilation unit — it has to be
21
+ # wrapped to be parsed at all. The wrapper name is irrelevant to the parse and never reaches disk.
22
+ PROBE_CLASS = "RigorSigGenProbe"
23
+
24
+ module_function
25
+
26
+ # @param line [String] a rendered RBS method line, e.g. `def self.parse: (String) -> Integer`.
27
+ # @return [String, nil] the parse error's first line, or nil when the line is valid.
28
+ def method_line_error(line)
29
+ return nil if line.nil?
30
+
31
+ source_error("class #{PROBE_CLASS}\n #{line}\nend\n")
32
+ end
33
+
34
+ # @param source [String] a complete `.rbs` file's text.
35
+ # @return [String, nil] the parse error's first line, or nil when the file is valid.
36
+ def source_error(source)
37
+ ::RBS::Parser.parse_signature(::RBS::Buffer.new(name: "(rigor sig-gen)", content: source))
38
+ nil
39
+ rescue ::RBS::ParsingError => e
40
+ e.message.to_s.lines.first.to_s.strip
41
+ end
42
+ end
43
+ end
44
+ end
@@ -126,6 +126,7 @@ module Rigor
126
126
  when :created then render_write_created(result)
127
127
  when :updated then render_write_updated(result)
128
128
  when :skipped_outside_sig_root then render_write_skipped(result)
129
+ when :skipped_invalid_rbs then render_write_invalid(result)
129
130
  end
130
131
  end
131
132
  end
@@ -143,6 +144,15 @@ module Rigor
143
144
  @out.puts("skipped #{result.source_path} -> #{result.target_path} (outside sig root)")
144
145
  end
145
146
 
147
+ # The assembled file does not parse, so it was NOT written. Writing it would poison the sig tree — the
148
+ # consumer quarantines an unparseable `.rbs` whole, taking every other type in that file down with it,
149
+ # including the user's own hand-written ones.
150
+ def render_write_invalid(result)
151
+ @out.puts("REFUSED #{result.target_path} — the generated RBS does not parse, so it was not written")
152
+ @out.puts(" #{result.error}")
153
+ @out.puts(" This is a bug in Rigor's RBS rendering, not in your code — please report it.")
154
+ end
155
+
146
156
  def render_write_json(results)
147
157
  @out.puts(JSON.pretty_generate({ results: results.map(&:to_h) }))
148
158
  end
@@ -10,19 +10,24 @@ module Rigor
10
10
  # - `source_path` — original `.rb` file.
11
11
  # - `target_path` — `.rbs` file the writer was responsible for (`nil` when the source path falls outside
12
12
  # the project signature tree, in which case `action` is `:skipped_outside_sig_root`).
13
- # - `action` — one of `:created` / `:updated` / `:noop` / `:skipped_outside_sig_root`.
13
+ # - `action` — one of `:created` / `:updated` / `:noop` / `:skipped_outside_sig_root` /
14
+ # `:skipped_invalid_rbs`.
14
15
  # - `applied` — the {MethodCandidate}s that actually landed on disk.
15
16
  # - `skipped` — the {MethodCandidate}s the writer declined (e.g. tighter-return without `--overwrite`). Each
16
17
  # entry pairs the candidate with a skip reason keyword (`:user_authored`).
18
+ # - `error` — the parse error, when `action` is `:skipped_invalid_rbs`: the file the writer assembled does
19
+ # not parse, so it was NOT written (writing it would poison the project's sig tree — the consumer
20
+ # quarantines an unparseable `.rbs`, taking every other type in that file down with it).
17
21
  class WriteResult
18
- attr_reader :source_path, :target_path, :action, :applied, :skipped
22
+ attr_reader :source_path, :target_path, :action, :applied, :skipped, :error
19
23
 
20
- def initialize(source_path:, target_path:, action:, applied: [], skipped: [])
24
+ def initialize(source_path:, target_path:, action:, applied: [], skipped: [], error: nil)
21
25
  @source_path = source_path
22
26
  @target_path = target_path
23
27
  @action = action
24
28
  @applied = applied.freeze
25
29
  @skipped = skipped.freeze
30
+ @error = error
26
31
  freeze
27
32
  end
28
33
 
@@ -33,7 +38,7 @@ module Rigor
33
38
  action: action.to_s,
34
39
  applied: applied.map(&:to_h),
35
40
  skipped: skipped.map { |c, reason| c.to_h.merge(write_skip_reason: reason.to_s) }
36
- }
41
+ }.tap { |h| h[:error] = error if error }
37
42
  end
38
43
  end
39
44
  end
@@ -108,8 +108,15 @@ module Rigor
108
108
  end
109
109
 
110
110
  def create_new(source_path, target, candidates)
111
+ content = render_new_file(candidates)
112
+ error = RbsValidity.source_error(content)
113
+ if error
114
+ return WriteResult.new(source_path: source_path, target_path: target,
115
+ action: :skipped_invalid_rbs, error: error)
116
+ end
117
+
111
118
  FileUtils.mkdir_p(target.dirname)
112
- target.write(render_new_file(candidates))
119
+ target.write(content)
113
120
  WriteResult.new(source_path: source_path, target_path: target,
114
121
  action: :created, applied: candidates)
115
122
  end
@@ -277,7 +284,22 @@ module Rigor
277
284
  merge_class_shells(state, collect_class_shells(candidates), merged_namespace_kinds(candidates))
278
285
 
279
286
  action = state.applied.empty? ? :noop : :updated
280
- target.write(state.source) if action == :updated
287
+ unless action == :updated
288
+ return WriteResult.new(source_path: source_path, target_path: target, action: action,
289
+ applied: state.applied, skipped: state.skipped)
290
+ end
291
+
292
+ # The merge splices text into an existing file by byte offset, so a bug here can produce a file neither
293
+ # the generator nor the target was responsible for. Parse the assembled result and refuse to write it if
294
+ # it is broken: a `.rbs` the consumer cannot parse is quarantined whole, so a bad splice would delete
295
+ # every type in the file — including the user's own, which were fine before we touched them.
296
+ error = RbsValidity.source_error(state.source)
297
+ if error
298
+ return WriteResult.new(source_path: source_path, target_path: target,
299
+ action: :skipped_invalid_rbs, error: error)
300
+ end
301
+
302
+ target.write(state.source)
281
303
  WriteResult.new(source_path: source_path, target_path: target,
282
304
  action: action, applied: state.applied, skipped: state.skipped)
283
305
  end