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
@@ -1,7 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "did_you_mean"
4
+
3
5
  require_relative "signature_path_audit"
4
- require_relative "analysis/check_rules"
6
+ # ADR-87 WD4 — only the pure rule-id table (`ALL_RULES` / `RULE_FAMILIES`) is needed, so require the light
7
+ # `rule_ids.rb` rather than the engine-pulling `check_rules.rb`; keeps `config_audit` (loaded by every check)
8
+ # off the inference engine so the boot-slimming hit path stays engine-free.
9
+ require_relative "analysis/check_rules/rule_ids"
5
10
 
6
11
  module Rigor
7
12
  # Audits a loaded {Configuration} for the class of mistake where a configured value
@@ -36,12 +41,53 @@ module Rigor
36
41
  # paths resolve against (the CLI's CWD), used only by the explicit-path checks.
37
42
  # @return [Array<Warning>]
38
43
  def self.warnings(configuration, project_root: Dir.pwd)
39
- signature_path_warnings(configuration) +
44
+ unknown_key_warnings(configuration) +
45
+ signature_path_warnings(configuration) +
40
46
  library_warnings(configuration) +
41
47
  rule_token_warnings(configuration) +
42
48
  explicit_path_warnings(configuration, project_root)
43
49
  end
44
50
 
51
+ # Top-level keys the loader does not own. The archetypal case is a typo — `excludee:` for
52
+ # `exclude:` — which the loader drops in silence, so the exclusion never applies and the run
53
+ # reports errors from the very files the user meant to skip, with nothing anywhere saying the key
54
+ # did nothing. That is exactly this module's class of mistake, and it was the one whole-key
55
+ # instance of it going unwarned.
56
+ #
57
+ # A reserved namespace ({Configuration::RESERVED_NAMESPACES}) is unknown *on purpose* — another
58
+ # implementation reads it from the same file — so it is exempt. {Configuration#unknown_keys}
59
+ # already applies that exemption; warning here would push users to delete the key their other
60
+ # tool needs.
61
+ #
62
+ # **Top level only, and deliberately.** A nested check would have to name each group's known keys,
63
+ # and DEFAULTS cannot supply them: `DEFAULTS["dependencies"]` carries `source_inference` and
64
+ # `budget_per_gem` but not `budget_overrun_strategy`, which is real (`Configuration::Dependencies`
65
+ # reads it), documented, and schema-declared — so a DEFAULTS-keyed nested check would flag a
66
+ # working config. `severity_overrides:` is an open map of rule ids besides. Nested unknown keys are
67
+ # the schema tier's job: every nested object in `schemas/rigor-config.schema.json` is
68
+ # `additionalProperties: false`, and `config_schema_spec` keeps it complete
69
+ # ([ADR-99](../../docs/adr/99-config-schema-authority.md), `docs/internal-spec/config.md`).
70
+ def self.unknown_key_warnings(configuration)
71
+ configuration.unknown_keys.map do |key|
72
+ suggestion = suggest_config_key(key)
73
+ hint = suggestion ? " Did you mean `#{suggestion}`?" : ""
74
+ Warning.new(
75
+ kind: :unknown_key,
76
+ message: "`#{key}` is not a recognized configuration key; it has no effect.#{hint}",
77
+ fields: { "key" => key, "suggestion" => suggestion }
78
+ )
79
+ end
80
+ end
81
+
82
+ # Nearest known key, or nil. Uses `DidYouMean::SpellChecker` — the engine behind Ruby's own
83
+ # `NoMethodError` hints, and the one `Plugin::Base.suggest` wraps. Called directly rather than
84
+ # through that helper because `plugin/base.rb` pulls Prism and the diagnostic model in, and
85
+ # ADR-87 WD4 keeps this file (loaded by every check) off that path; `did_you_mean` is stdlib.
86
+ def self.suggest_config_key(key)
87
+ DidYouMean::SpellChecker.new(dictionary: Configuration::KNOWN_KEYS).correct(key.to_s).first
88
+ end
89
+ private_class_method :suggest_config_key
90
+
45
91
  # `signature_paths:` entries that resolve to nothing — delegated to {SignaturePathAudit},
46
92
  # which mirrors the loader's `directory?` + recursive `**/*.rbs` acceptance test.
47
93
  def self.signature_path_warnings(configuration)
@@ -37,11 +37,15 @@ module Rigor
37
37
  "call.wrong-arity" => :error,
38
38
  "call.argument-type-mismatch" => :warning,
39
39
  "call.possible-nil-receiver" => :warning,
40
+ "call.raise-non-exception" => :warning,
40
41
  "flow.always-raises" => :warning,
41
42
  "flow.unreachable-branch" => :info,
42
43
  "flow.dead-assignment" => :info,
43
44
  "flow.always-truthy-condition" => :info,
44
45
  "flow.unreachable-clause" => :info,
46
+ "flow.duplicate-hash-key" => :info,
47
+ "flow.return-in-ensure" => :info,
48
+ "flow.shadowed-rescue-clause" => :info,
45
49
  "assert.type-mismatch" => :error,
46
50
  "dump.type" => :info,
47
51
  "def.return-type-mismatch" => :warning,
@@ -50,6 +54,16 @@ module Rigor
50
54
  "def.override-return-widened" => :off,
51
55
  "def.override-param-narrowed" => :off,
52
56
  "def.ivar-write-mismatch" => :warning,
57
+ # ADR-8 companion (PHPStan IgnoreParseErrorRule-modelled): a broken suppression comment is
58
+ # equally bad in every profile — it silently fails to do what the author believes it does — so
59
+ # both rules stay :warning across all three profiles (including strict).
60
+ "suppression.unknown-rule" => :warning,
61
+ "suppression.empty" => :warning,
62
+ "suppression.unknown-marker" => :warning,
63
+ # ADR-100 — a new required diagnostic (ADR-50 WD1), so it is `:off` in every shipped profile and
64
+ # reaches a user only through the `use-of-void-value` bleeding-edge feature, which overrides this
65
+ # to `:warning`.
66
+ "static.value-use.void" => :off,
53
67
  # Opt-in author assertion: you only see it if you wrote a
54
68
  # `conforms-to` directive, so it stays a :warning even in
55
69
  # lenient — it is never unsolicited noise.
@@ -62,6 +76,7 @@ module Rigor
62
76
  "call.wrong-arity" => :error,
63
77
  "call.argument-type-mismatch" => :error,
64
78
  "call.possible-nil-receiver" => :error,
79
+ "call.raise-non-exception" => :error,
65
80
  "flow.always-raises" => :error,
66
81
  "flow.unreachable-branch" => :warning,
67
82
  "flow.dead-assignment" => :warning,
@@ -71,6 +86,9 @@ module Rigor
71
86
  # green; promote to :warning once Mastodon/GitLab/Redmine triage
72
87
  # to zero net false positives.
73
88
  "flow.unreachable-clause" => :info,
89
+ "flow.duplicate-hash-key" => :warning,
90
+ "flow.return-in-ensure" => :warning,
91
+ "flow.shadowed-rescue-clause" => :warning,
74
92
  "assert.type-mismatch" => :error,
75
93
  "dump.type" => :info,
76
94
  "def.return-type-mismatch" => :warning,
@@ -79,6 +97,10 @@ module Rigor
79
97
  "def.override-return-widened" => :warning,
80
98
  "def.override-param-narrowed" => :warning,
81
99
  "def.ivar-write-mismatch" => :warning,
100
+ "suppression.unknown-rule" => :warning,
101
+ "suppression.empty" => :warning,
102
+ "suppression.unknown-marker" => :warning,
103
+ "static.value-use.void" => :off,
82
104
  "rbs_extended.unsatisfied-conformance" => :warning
83
105
  }.freeze,
84
106
  strict: {
@@ -88,6 +110,7 @@ module Rigor
88
110
  "call.wrong-arity" => :error,
89
111
  "call.argument-type-mismatch" => :error,
90
112
  "call.possible-nil-receiver" => :error,
113
+ "call.raise-non-exception" => :error,
91
114
  "flow.always-raises" => :error,
92
115
  "flow.unreachable-branch" => :error,
93
116
  "flow.dead-assignment" => :error,
@@ -96,6 +119,9 @@ module Rigor
96
119
  # below its :error siblings) while it proves out — see the
97
120
  # balanced-profile note above.
98
121
  "flow.unreachable-clause" => :warning,
122
+ "flow.duplicate-hash-key" => :error,
123
+ "flow.return-in-ensure" => :error,
124
+ "flow.shadowed-rescue-clause" => :error,
99
125
  "assert.type-mismatch" => :error,
100
126
  "dump.type" => :error,
101
127
  "def.return-type-mismatch" => :error,
@@ -104,6 +130,11 @@ module Rigor
104
130
  "def.override-return-widened" => :error,
105
131
  "def.override-param-narrowed" => :error,
106
132
  "def.ivar-write-mismatch" => :error,
133
+ "suppression.unknown-rule" => :warning,
134
+ "suppression.empty" => :warning,
135
+ "suppression.unknown-marker" => :warning,
136
+ # `:off` even under strict: the gate is `bleeding_edge:`, not the profile (ADR-50 WD1 / ADR-100).
137
+ "static.value-use.void" => :off,
107
138
  "rbs_extended.unsatisfied-conformance" => :error
108
139
  }.freeze
109
140
  }.freeze
@@ -3,6 +3,7 @@
3
3
  require "yaml"
4
4
 
5
5
  require_relative "bleeding_edge"
6
+ require_relative "ci_detector"
6
7
  require_relative "configuration/dependencies"
7
8
  require_relative "configuration/severity_profile"
8
9
 
@@ -55,6 +56,13 @@ module Rigor
55
56
  # filtering — the path must be named here (WD2 (b) of ADR-22).
56
57
  "baseline" => nil,
57
58
  "fold_platform_specific_paths" => false,
59
+ # ADR-67 WD6a — opt-in call-site parameter type inference on the `check` walk. When `true`, `rigor
60
+ # check` runs the {Inference::ParameterInferenceCollector} as a one-round pre-pass into the discovery
61
+ # seed, so an undeclared `def` / `initialize` / setter parameter is typed to the union of its resolved
62
+ # call-site argument types (precision-additive only — the in-body negative rules decline on the inferred
63
+ # lower bound, WD6b). `false` (the default, resolved off by every severity profile) keeps the table empty
64
+ # and the run byte-identical to today. Mutually exclusive with `--incremental` (WD6c).
65
+ "parameter_inference" => false,
58
66
  "cache" => {
59
67
  "path" => ".rigor/cache",
60
68
  # LRU eviction cap in bytes (ADR-54 WD3). The least-recently-used entries are removed at the end of a
@@ -63,7 +71,18 @@ module Rigor
63
71
  # would otherwise ever delete; a full active per-project set is ~2 MB, so the cap never touches live
64
72
  # entries. Set explicitly to `null` to disable eviction (pre-WD3 behaviour: the cache grows until
65
73
  # `--clear-cache`).
66
- "max_bytes" => 268_435_456
74
+ "max_bytes" => 268_435_456,
75
+ # ADR-87 WD1 — file-freshness validation strategy. `"stat"` validates a recorded file
76
+ # dependency by stat-ing it first and re-hashing only when the `(size, mtime_ns, ctime_ns, inode)`
77
+ # tuple moved (or the racy window fires), so an unchanged monorepo hashes ~0 bytes on a warm run.
78
+ # `"digest"` restores the pre-ADR-87 behaviour of SHA-256'ing every recorded file on every run — the
79
+ # per-run escape hatch is the `RIGOR_STRICT_VALIDATION=1` env var, which wins over this setting.
80
+ # `"auto"` (default, #190) resolves per environment: `"digest"` when {CiDetector} recognises a CI
81
+ # provider — a fresh checkout regenerates every stat tuple, so the stat tier can never short-circuit
82
+ # there and stat-signature glob slots would recompute on every run — and `"stat"` everywhere else.
83
+ # A self-hosted runner with a persistent workspace opts back into the stat floor with an explicit
84
+ # `"stat"` (or `RIGOR_CI_DETECT=0`).
85
+ "validation" => "auto"
67
86
  },
68
87
  "plugins_io" => {
69
88
  "network" => "disabled",
@@ -143,9 +162,45 @@ module Rigor
143
162
  PATH_KEYS = %w[paths signature_paths pre_eval].freeze
144
163
  private_constant :PATH_KEYS
145
164
 
165
+ # Top-level keys this implementation DECLARES but never reads — see `docs/internal-spec/config.md`
166
+ # § "Reserved namespaces" and ADR-99.
167
+ #
168
+ # A sibling implementation (`rigor-rs`, which vendors our schema rather than keeping its own)
169
+ # groups keys for concepts we do not have under its own namespace, so one `.rigor.yml` feeds both.
170
+ # Such a key answers to the SCHEMA TIER ONLY: it is type-checked where it is written, and here it
171
+ # is never read, never validated, never coerced, and never an error however invalid its value.
172
+ #
173
+ # That is already the emergent behaviour — `#initialize` fetches each key it owns and never
174
+ # enumerates the rest — so this constant does not gate the runtime. It exists to make the
175
+ # reservation FINDABLE by the next person adding config validation (#166 is exactly that), and to
176
+ # give the schema-parity spec something to key on: a reserved namespace is by definition absent
177
+ # from DEFAULTS, so the DEFAULTS-driven schema gate can never see it.
178
+ RESERVED_NAMESPACES = %w[rigor_rs].freeze
179
+
180
+ # Every top-level key a conforming `.rigor.yml` may carry: the keys this implementation owns, plus
181
+ # `includes:`, plus the namespaces reserved for another implementation. Anything else is recorded in
182
+ # {#unknown_keys} and warned about by {ConfigAudit}, and is also the did-you-mean dictionary for a
183
+ # near-miss.
184
+ #
185
+ # `includes:` is a load-time directive — {load_with_includes} `delete`s it while merging, so it never
186
+ # reaches `#initialize` and could not be seen as unknown regardless. It is listed because a user
187
+ # legitimately writes it, so a typo like `include:` must be able to suggest it.
188
+ KNOWN_KEYS = (DEFAULTS.keys + %w[includes] + RESERVED_NAMESPACES).freeze
189
+
190
+ # Top-level keys the loaded config carried that this implementation does not own — neither a
191
+ # {DEFAULTS} key, nor `includes:`, nor a reserved namespace. Recorded rather than acted on: an
192
+ # unknown key stays as inert at run time as it has always been, and {ConfigAudit} turns the record
193
+ # into a warning. Empty for every conforming config.
194
+ #
195
+ # This exists because `#initialize` fetches each key it owns and never enumerates the rest, so by
196
+ # the time anything holds a Configuration the unknown keys are gone. The audit reads a
197
+ # Configuration, so without this the class of mistake it exists to catch — a value that silently
198
+ # resolves to nothing — was structurally invisible to it for whole keys.
199
+ attr_reader :unknown_keys
200
+
146
201
  attr_reader :target_ruby, :paths, :exclude_patterns, :plugins, :cache_path, :cache_max_bytes,
147
- :disabled_rules,
148
- :libraries, :signature_paths, :fold_platform_specific_paths,
202
+ :cache_validation, :disabled_rules,
203
+ :libraries, :signature_paths, :fold_platform_specific_paths, :parameter_inference,
149
204
  :plugins_io_network, :plugins_io_allowed_paths,
150
205
  :plugins_io_allowed_url_hosts,
151
206
  :severity_profile, :severity_overrides,
@@ -168,10 +223,67 @@ module Rigor
168
223
  # [PHPStan](https://phpstan.org/config-reference#paths).
169
224
  def self.load(path = nil)
170
225
  resolved = path || discover
171
- return new(DEFAULTS) if resolved.nil? || !File.exist?(resolved)
226
+ data =
227
+ if resolved.nil? || !File.exist?(resolved)
228
+ DEFAULTS
229
+ else
230
+ DEFAULTS.merge(load_with_includes(resolved))
231
+ end
232
+ new(autowire_default_plugins(data))
233
+ end
172
234
 
173
- data = load_with_includes(resolved)
174
- new(DEFAULTS.merge(data))
235
+ # ADR-93 WD2 — the one bundled plugin default-wired without a `plugins:` entry. `rigor-rbs-inline` is the
236
+ # gem; `rbs-inline` is its manifest id (the loader raises on a duplicate id, so both forms count as
237
+ # "already listed").
238
+ AUTOWIRED_RBS_INLINE_GEM = "rigor-rbs-inline"
239
+ AUTOWIRED_RBS_INLINE_ID = "rbs-inline"
240
+
241
+ # ADR-93 WD2 — default-wire the bundled `rigor-rbs-inline` plugin, in WD1's annotation-gated,
242
+ # magic-comment-free mode, when the upstream `rbs-inline` library is resolvable and the user has not
243
+ # already listed the plugin. Runs from {load} only — the real-project route — never from a bare
244
+ # {Configuration.new}, so the suite's unit constructions do not auto-wire. The gate is presence-based
245
+ # (ADR-72's shape): an annotation-free project pays only a comment scan and contributes nothing, so
246
+ # wiring it on cannot regress the run. This partially reverses the ADR-27/ADR-31 auto-load deferral for
247
+ # this one bundled plugin (the executed code is already vendored, not arbitrary third-party plugin code).
248
+ # Opt out with a `plugins:` entry disabling it (`enabled: false`), or per-file `# rbs_inline: disabled`.
249
+ def self.autowire_default_plugins(data)
250
+ entries = Array(data["plugins"])
251
+ return data if entries.any? { |entry| rbs_inline_plugin_entry?(entry) }
252
+ return data unless rbs_inline_library_resolvable?
253
+
254
+ merged = data.dup
255
+ merged["plugins"] = entries + [{
256
+ "gem" => AUTOWIRED_RBS_INLINE_GEM,
257
+ "id" => AUTOWIRED_RBS_INLINE_ID,
258
+ "config" => { "require_magic_comment" => false }
259
+ }]
260
+ merged
261
+ end
262
+
263
+ # True when a `plugins:` entry (String or Hash, string- or symbol-keyed) already references the
264
+ # `rigor-rbs-inline` plugin by gem name or by manifest id. Single-homed here so the CLI's
265
+ # `--treat-all-as-inline-rbs` injection and the WD2 auto-wire gate agree on what "already listed" means.
266
+ def self.rbs_inline_plugin_entry?(entry)
267
+ case entry
268
+ when String
269
+ entry == AUTOWIRED_RBS_INLINE_GEM
270
+ when Hash
271
+ string_keyed = entry.to_h { |k, v| [k.to_s, v] }
272
+ string_keyed["gem"] == AUTOWIRED_RBS_INLINE_GEM || string_keyed["id"] == AUTOWIRED_RBS_INLINE_ID
273
+ else
274
+ false
275
+ end
276
+ end
277
+
278
+ # A probe with no load side effect: it does not `require` the library, only asks whether RubyGems can
279
+ # resolve the name. `Gem::Specification.find_by_name` raises `Gem::MissingSpecError` (a `Gem::LoadError`)
280
+ # when the gem is absent — the standalone `gem install rigortype` case, WD3's residual. ADR-90's
281
+ # project-bundle fallback resolves the same name at the plugin's own `require` time.
282
+ def self.rbs_inline_library_resolvable?
283
+ Gem::Specification.find_by_name("rbs-inline")
284
+ true
285
+ rescue Gem::LoadError
286
+ false
175
287
  end
176
288
 
177
289
  # Returns the path to the config file Rigor would load under auto-discovery, or `nil` when neither
@@ -280,6 +392,10 @@ module Rigor
280
392
 
281
393
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
282
394
  def initialize(data = DEFAULTS)
395
+ # Record before the per-key fetches below discard the evidence. Top level only, deliberately —
396
+ # see {ConfigAudit.unknown_key_warnings} for why a nested check cannot key on DEFAULTS.
397
+ @unknown_keys = (data.keys.map(&:to_s) - KNOWN_KEYS).sort.freeze
398
+
283
399
  cache = DEFAULTS.fetch("cache").merge(data.fetch("cache", {}))
284
400
  plugins_io = DEFAULTS.fetch("plugins_io").merge(data.fetch("plugins_io", {}))
285
401
 
@@ -300,10 +416,14 @@ module Rigor
300
416
  @baseline_path = coerce_baseline_path(data.fetch("baseline", DEFAULTS.fetch("baseline")))
301
417
  @fold_platform_specific_paths = data.fetch(
302
418
  "fold_platform_specific_paths", DEFAULTS.fetch("fold_platform_specific_paths")
303
- ) == true
419
+ )
420
+ # ADR-67 WD6a — resolve to a strict Boolean so a truthy non-`true` value (e.g. a stray String) does not
421
+ # silently enable the gate; only the literal `true` activates the check-walk collector pre-pass.
422
+ @parameter_inference = data.fetch("parameter_inference", DEFAULTS.fetch("parameter_inference")) == true
304
423
  @cache_path = cache.fetch("path").to_s
305
424
  raw_max = cache.fetch("max_bytes")
306
425
  @cache_max_bytes = raw_max.nil? ? nil : Integer(raw_max)
426
+ @cache_validation = coerce_cache_validation(cache.fetch("validation", "auto"))
307
427
  @plugins_io_network = coerce_network_policy(plugins_io.fetch("network"))
308
428
  @plugins_io_allowed_paths = Array(plugins_io.fetch("allowed_paths")).map(&:to_s).freeze
309
429
  @plugins_io_allowed_url_hosts = Array(plugins_io.fetch("allowed_url_hosts")).map(&:to_s).freeze
@@ -340,6 +460,20 @@ module Rigor
340
460
  end
341
461
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
342
462
 
463
+ # Resolves the `cache.validation` tri-state to the boolean the run installs via
464
+ # {Cache::FileDigest.with_run}'s `strict:`. Explicit `"digest"` / `"stat"` win outright; the `"auto"`
465
+ # default is strict exactly when {CiDetector} recognises a CI provider (#190) — deliberately NOT stored at
466
+ # construction so the resolution honours the environment of the run, not of `Configuration.load`, and the
467
+ # frozen carrier stays env-independent. `RIGOR_STRICT_VALIDATION=1` is enforced separately inside
468
+ # {Cache::FileDigest.strict_validation?} and wins over all three values.
469
+ def cache_validation_strict?(env = ENV)
470
+ case cache_validation
471
+ when "digest" then true
472
+ when "stat" then false
473
+ else !CiDetector.detect(env).nil?
474
+ end
475
+ end
476
+
343
477
  def to_h # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
344
478
  {
345
479
  "target_ruby" => target_ruby,
@@ -351,9 +485,11 @@ module Rigor
351
485
  "signature_paths" => signature_paths,
352
486
  "pre_eval" => pre_eval,
353
487
  "fold_platform_specific_paths" => fold_platform_specific_paths,
488
+ "parameter_inference" => parameter_inference,
354
489
  "cache" => {
355
490
  "path" => cache_path,
356
- "max_bytes" => cache_max_bytes
491
+ "max_bytes" => cache_max_bytes,
492
+ "validation" => cache_validation
357
493
  },
358
494
  "plugins_io" => {
359
495
  "network" => plugins_io_network.to_s,
@@ -483,6 +619,18 @@ module Rigor
483
619
  value.to_s
484
620
  end
485
621
 
622
+ # ADR-87 WD1 — `cache.validation` is `"auto"` (default), `"stat"`, or `"digest"`. An unrecognised value
623
+ # fails soft to the default rather than aborting a check over a typo — the strict `"digest"` behaviour is
624
+ # the safe fallback either way, and the always-available `RIGOR_STRICT_VALIDATION=1` env escape hatch does
625
+ # not depend on this value being valid.
626
+ VALID_CACHE_VALIDATIONS = %w[auto stat digest].freeze
627
+ private_constant :VALID_CACHE_VALIDATIONS
628
+
629
+ def coerce_cache_validation(value)
630
+ str = value.to_s
631
+ VALID_CACHE_VALIDATIONS.include?(str) ? str : "auto"
632
+ end
633
+
486
634
  def coerce_network_policy(value)
487
635
  sym = value.to_sym
488
636
  unless VALID_NETWORK_POLICIES.include?(sym)
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class Environment
5
+ # Slice A stdlib expansion. Stdlib libraries that `Environment.for_project` loads on top of RBS core
6
+ # unless the caller passes an explicit `libraries:` array. Each entry MUST be a stdlib library name
7
+ # accepted by `RBS::EnvironmentLoader#has_library?`; unknown libraries MUST fail-soft
8
+ # (`RbsLoader#build_env` already filters through `has_library?`). The default set covers the common
9
+ # stdlib surface a Ruby program is likely to import, plus the analyzer-adjacent gems shipping their own
10
+ # RBS in this bundle (`prism`, `rbs`). The list below is the enumeration — no prose gloss restates a
11
+ # subset of it, here or in `docs/internal-spec/inference-engine.md` § Environment Surface, because a
12
+ # restated subset silently rots as entries are added. On hosts where one of these libraries is not
13
+ # installed, the loader silently drops it.
14
+ #
15
+ # Callers MAY add to the default by passing `libraries: %w[csv ...]`; the explicit list is appended to
16
+ # `DEFAULT_LIBRARIES` and de-duplicated. Callers that need a strictly RBS-core view MUST construct an
17
+ # `RbsLoader` directly instead of going through `for_project`.
18
+ #
19
+ # ADR-87 WD4 — extracted to its own light file (no engine requires) so the boot-slimming hit probe can
20
+ # reconstruct `Environment.for_project`'s merged library list — which feeds the run cache KEY's
21
+ # `rbs.libraries` slot — WITHOUT building the RBS environment (or loading the inference engine).
22
+ DEFAULT_LIBRARIES = %w[
23
+ pathname optparse json yaml fileutils tempfile tmpdir
24
+ stringio forwardable digest securerandom
25
+ uri logger date
26
+ pp delegate observable abbrev find tsort singleton
27
+ shellwords benchmark base64 did_you_mean
28
+ monitor mutex_m timeout
29
+ open3 erb etc ipaddr bigdecimal bigdecimal-math
30
+ prettyprint random-formatter time open-uri resolv
31
+ csv pstore objspace io-console cgi cgi-escape
32
+ strscan
33
+ prism rbs
34
+ ].freeze
35
+ end
36
+ end
@@ -90,10 +90,48 @@ module Rigor
90
90
  add_project_signatures(env, signature_paths)
91
91
  add_virtual_rbs(env, virtual_rbs)
92
92
  synthesize_missing_namespaces(env)
93
- resolved = env.resolve_type_names
93
+ env, resolved = resolve_quarantining_virtual_collisions(env, virtual_rbs)
94
94
  stub_missing_referenced_types(env, resolved, project_sig_files(signature_paths))
95
95
  end
96
96
 
97
+ # True when `content` parses as an RBS signature. {#virtual_rbs_collision_quarantined} uses this to
98
+ # tell a collision-dropped virtual entry (parses, but absent from the env) from a parse-failed one
99
+ # (the synthesizer's own WD6 skip, reported separately).
100
+ def parseable_rbs?(content)
101
+ ::RBS::Parser.parse_signature(::RBS::Buffer.new(name: "(rigor: virtual parse check)", content: content))
102
+ true
103
+ rescue ::RBS::BaseError
104
+ false
105
+ end
106
+
107
+ # Backstop for a virtual-vs-anything `RBS::DuplicatedDeclarationError` that only materialises at
108
+ # `resolve_type_names` (which rebuilds the env from `sources`). {.add_virtual_rbs}'s transactional
109
+ # rescue already handles the add-time case — empirically everything on rbs 4.x — but the rbs gemspec
110
+ # range spans `>= 3.0, < 5.0` (ADR-79) and WHERE duplicate detection fires is an rbs-internal choice
111
+ # this code must not depend on. Resolution rule is the same as the add-time path: the explicit
112
+ # signature wins, the colliding VIRTUAL buffer is dropped whole (`RBS::Environment#unload`) and
113
+ # resolution retries; every pass removes at least one virtual buffer, so the loop is bounded by the
114
+ # virtual-entry count. A duplicate involving no virtual buffer (sig-vs-sig), or an env without
115
+ # `#unload` (rbs 3.x), re-raises into the existing one-warning degrade path.
116
+ #
117
+ # The dropped set is not returned: consumers recover it from the built env via
118
+ # {#virtual_rbs_collision_quarantined}, which also works on a cache HIT where this build never ran.
119
+ def resolve_quarantining_virtual_collisions(env, virtual_rbs)
120
+ virtual_names = virtual_rbs.to_set { |name, _content| name.to_s }
121
+ (virtual_names.size + 1).times do
122
+ return [env, env.resolve_type_names]
123
+ rescue ::RBS::DuplicatedDeclarationError => e
124
+ raise unless env.respond_to?(:unload)
125
+
126
+ culprits = e.decls.filter_map { |decl| decl.location&.buffer&.name }
127
+ .uniq.select { |name| virtual_names.include?(name) }
128
+ raise if culprits.empty?
129
+
130
+ env = env.unload(culprits)
131
+ end
132
+ [env, env.resolve_type_names]
133
+ end
134
+
97
135
  # ADR-5 robustness, second tier. A project `signature_paths:` RBS that *references* a type no loaded
98
136
  # signature declares — `def x: () -> DRb::DRbServer` when the `drb` RBS is not available, or a stale
99
137
  # reference to its own removed `Textbringer::EditorError` — makes
@@ -356,9 +394,20 @@ module Rigor
356
394
  _, directives, decls = ::RBS::Parser.parse_signature(buffer)
357
395
  add_parsed_decls(env, buffer, directives, decls)
358
396
  rescue ::RBS::BaseError
359
- # WD6 fail-soft: a single broken virtual RBS contribution does not pull the whole env down. The
360
- # plugin layer records a `source-rbs-synthesis-failed` info diagnostic in slice 2; here we just
361
- # skip the entry.
397
+ # WD6 fail-soft: a single broken virtual RBS contribution does not pull the whole env down — for
398
+ # a parse error, skipping the entry is enough. But `RBS::Environment#add_source` appends to
399
+ # `env.sources` BEFORE inserting decls, so when the raise is a mid-insert
400
+ # `RBS::DuplicatedDeclarationError` (the entry declares a constant the project's own `sig/`
401
+ # already declares — the expected state for a project migrating between `sig/` and inline
402
+ # annotations, not an authoring error), the POISONED SOURCE is left behind, and
403
+ # `resolve_type_names` — which rebuilds the env from `sources` — re-raises the same error outside
404
+ # this rescue and collapses the WHOLE env to nil (measured on herb: 1,490 classes → 0, `require`
405
+ # itself stopped resolving, 74 false `call.unresolved-toplevel`). Make the skip transactional:
406
+ # drop the poisoned source. The explicit `.rbs` declaration wins — the spec keeps standalone
407
+ # `.rbs` files "the preferred place for complete type definitions" (`overview.md`) — and
408
+ # {#warn_about_virtual_rbs_collisions} names the dropped file. `sources` is the rbs 4.x shape;
409
+ # under the 3.x API this degrades to today's behaviour.
410
+ env.sources.reject! { |source| source.buffer.name == buffer.name } if env.respond_to?(:sources)
362
411
  end
363
412
  end
364
413
 
@@ -478,6 +527,55 @@ module Rigor
478
527
  names_synthesized_in(SYNTHETIC_NAMESPACE_BUFFER)
479
528
  end
480
529
 
530
+ # The project `signature_paths:` files that were QUARANTINED this run (they do not parse, so
531
+ # {RbsLoader.add_project_signatures} skipped them to keep the rest of the env alive), as
532
+ # `[absolute_path, first_error_line]` pairs. Memoised per loader: the detection re-parses only the user's
533
+ # own `sig/` set, but every consumer (the `rbs.coverage.quarantined-signature` diagnostic, `rigor doctor`,
534
+ # the stderr banner) reads it, and a cache HIT reaches it too — the env was built with the file already
535
+ # quarantined, so the condition is invisible in the cached env itself.
536
+ #
537
+ # @return [Array<Array(String, String)>] empty when every `signature_paths:` file parses.
538
+ def quarantined_signatures
539
+ @state[:quarantined] ||= self.class.quarantined_project_signatures(@signature_paths).freeze
540
+ end
541
+
542
+ # The total RBS-environment build failure captured this run, or nil when the env built. Unlike
543
+ # {#quarantined_signatures} — which the env survives, one file lighter, and which is re-derived by
544
+ # re-parsing so a cache HIT reports it too — a total failure (typically `RBS::DuplicatedDeclarationError`:
545
+ # a `signature_paths:` entry redeclaring a constant/class Rigor's bundled RBS already ships) collapses the
546
+ # WHOLE env to nil. A failed build produces no cached success to hide behind (nothing is persisted, so
547
+ # every run re-attempts and re-raises), so this is captured directly in {#env}'s rescue rather than
548
+ # re-derived. Forcing `env` (any query does) populates it.
549
+ #
550
+ # @return [Array(String, String, Array<String>), nil] `[error_class_name, first_error_line,
551
+ # conflicting_buffer_names]`, or nil when the environment built successfully.
552
+ def env_build_failure
553
+ env unless @state[:env_loaded]
554
+ @state[:env_build_failure]
555
+ end
556
+
557
+ # Virtual (inline-synthesized) contributions dropped by the collision quarantine
558
+ # ({.resolve_quarantining_virtual_collisions}): buffer names absent from the built env even though the
559
+ # entry's content is non-empty and parses (a parse failure is the synthesizer's own WD6 skip, reported
560
+ # through the synthesis reporter instead). Derived from the env rather than recorded during build —
561
+ # the {#quarantined_signatures} trick — so a cache HIT, which never runs the build, reports the same
562
+ # condition: the marshalled env simply lacks the dropped buffers.
563
+ #
564
+ # @return [Array<String>] virtual buffer names (source-file paths) whose contribution was dropped.
565
+ def virtual_rbs_collision_quarantined
566
+ @state[:virtual_rbs_collisions] ||= begin
567
+ built = @state[:env]
568
+ if built.nil? || @virtual_rbs.empty?
569
+ [].freeze
570
+ else
571
+ present = built.buffers.to_set(&:name)
572
+ @virtual_rbs.filter_map do |name, content|
573
+ name if !content.empty? && !present.include?(name) && self.class.parseable_rbs?(content)
574
+ end.freeze
575
+ end
576
+ end
577
+ end
578
+
481
579
  # The referenced-but-undeclared types {.stub_missing_referenced_types} stubbed so the project classes
482
580
  # that mention them could build (e.g. an unavailable `DRb::DRbServer`, or a stale
483
581
  # `Textbringer::EditorError`). Recovered off the built env like {#synthesized_namespaces}, so it
@@ -970,8 +1068,10 @@ module Rigor
970
1068
  @state[:env_loaded] = true
971
1069
  @state[:env] = cache_store ? cached_env : build_env
972
1070
  warn_about_quarantined_signatures
1071
+ warn_about_virtual_rbs_collisions
973
1072
  @state[:env]
974
1073
  rescue ::RBS::BaseError => e
1074
+ record_env_build_failure(e)
975
1075
  warn_about_env_build_failure_once(e)
976
1076
  @state[:env] = nil
977
1077
  end
@@ -982,10 +1082,15 @@ module Rigor
982
1082
  # is the visibility half of the fix: a shrinking diagnostic count must never be mistaken for a clean run
983
1083
  # when it actually means "your sig/ stopped loading". No-op when `signature_paths:` is empty (the cost is
984
1084
  # then a single empty-set check) or every file parses.
1085
+ #
1086
+ # `rigor check` ALSO reports this as the `rbs.coverage.quarantined-signature` diagnostic, which is what
1087
+ # reaches JSON / SARIF / CI annotations / the LSP. The banner is kept because the commands that build an
1088
+ # env WITHOUT producing a diagnostic stream (`coverage`, `sig-gen`) have no other channel, and a silently
1089
+ # degraded env is exactly what misleads there too.
985
1090
  def warn_about_quarantined_signatures
986
1091
  return if @state[:quarantine_warned]
987
1092
 
988
- quarantined = self.class.quarantined_project_signatures(@signature_paths)
1093
+ quarantined = quarantined_signatures
989
1094
  return if quarantined.empty?
990
1095
 
991
1096
  @state[:quarantine_warned] = true
@@ -1002,6 +1107,60 @@ module Rigor
1002
1107
  )
1003
1108
  end
1004
1109
 
1110
+ # The collision twin of {#warn_about_quarantined_signatures}: name, once per run, the source files
1111
+ # whose inline-synthesized RBS was dropped because it collides with a declaration another signature
1112
+ # source already made ({.resolve_quarantining_virtual_collisions} — the explicit `.rbs` wins). Without
1113
+ # this the drop is silent, and "my `#:` annotation does nothing" has no visible cause. Reads the
1114
+ # derived {#virtual_rbs_collision_quarantined}, so a cache HIT warns identically.
1115
+ def warn_about_virtual_rbs_collisions
1116
+ return if @state[:virtual_collision_warned]
1117
+
1118
+ dropped = virtual_rbs_collision_quarantined
1119
+ return if dropped.empty?
1120
+
1121
+ @state[:virtual_collision_warned] = true
1122
+ listed = dropped.first(QUARANTINE_WARN_LIMIT)
1123
+ more = dropped.size - listed.size
1124
+ lines = listed.map { |name| " - #{name}" }
1125
+ lines << " … and #{more} more" if more.positive?
1126
+ warn(
1127
+ "rigor: dropped inline-RBS contribution(s) from #{dropped.size} file(s): they declare a
1128
+ " \
1129
+ "constant, alias, or global that another signature source (typically the project's own
1130
+ " \
1131
+ "`sig/`) already declares. The explicit `.rbs` declaration wins, so inline annotations in
1132
+ " \
1133
+ "these files do not bind. Remove the duplication from either side to restore them:
1134
+ " \
1135
+ "#{lines.join("\n")}"
1136
+ )
1137
+ end
1138
+
1139
+ # Records the total RBS-environment build failure captured in {#env}'s rescue so the analysis layer can
1140
+ # surface it as the `rbs.coverage.environment-build-failed` diagnostic (the twin of
1141
+ # {#quarantined_signatures}: quarantine keeps the env alive minus one file, a total failure collapses the
1142
+ # WHOLE env to nil, so every type-of query degrades to `Dynamic[top]` and most rules stop firing). Stored
1143
+ # as `[error_class_name, first_error_line, conflicting_buffer_names]`. The buffer names are lifted off a
1144
+ # `RBS::DuplicatedDeclarationError#decls` — the typical failure, a `signature_paths:` entry redeclaring a
1145
+ # constant/class Rigor's bundled RBS already ships — so the diagnostic can name the colliding files.
1146
+ def record_env_build_failure(error)
1147
+ first_line = error.message.to_s.lines.first.to_s.strip
1148
+ @state[:env_build_failure] = [error.class.name, first_line, env_build_conflict_buffers(error)].freeze
1149
+ end
1150
+
1151
+ # The buffer (file) names carried by the colliding declarations of a `RBS::DuplicatedDeclarationError`, so
1152
+ # the diagnostic names the conflicting signature files rather than guessing. A buffer name is a `String`
1153
+ # path for a project `signature_paths:` file and a `Pathname` for a bundled RBS file, so each is coerced
1154
+ # to `String`. Other RBS build errors carry no `.decls`; they yield an empty list and the diagnostic
1155
+ # falls back to the message's first line alone.
1156
+ def env_build_conflict_buffers(error)
1157
+ return [].freeze unless error.respond_to?(:decls)
1158
+
1159
+ error.decls.filter_map { |decl| decl.location&.buffer&.name }.map(&:to_s).uniq.freeze
1160
+ rescue ::RBS::BaseError, StandardError
1161
+ [].freeze
1162
+ end
1163
+
1005
1164
  def warn_about_env_build_failure_once(error)
1006
1165
  return if @state[:env_build_warned]
1007
1166