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
@@ -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,35 @@
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 (`pathname`, `optparse`, `json`, `yaml`, `fileutils`,
10
+ # `tempfile`, `uri`, `logger`, `date`) plus the analyzer-adjacent gems shipping their own RBS in this
11
+ # bundle (`prism`, `rbs`). On hosts where one of these libraries is not installed, the loader silently
12
+ # drops it.
13
+ #
14
+ # Callers MAY add to the default by passing `libraries: %w[csv ...]`; the explicit list is appended to
15
+ # `DEFAULT_LIBRARIES` and de-duplicated. Callers that need a strictly RBS-core view MUST construct an
16
+ # `RbsLoader` directly instead of going through `for_project`.
17
+ #
18
+ # ADR-87 WD4 — extracted to its own light file (no engine requires) so the boot-slimming hit probe can
19
+ # reconstruct `Environment.for_project`'s merged library list — which feeds the run cache KEY's
20
+ # `rbs.libraries` slot — WITHOUT building the RBS environment (or loading the inference engine).
21
+ DEFAULT_LIBRARIES = %w[
22
+ pathname optparse json yaml fileutils tempfile tmpdir
23
+ stringio forwardable digest securerandom
24
+ uri logger date
25
+ pp delegate observable abbrev find tsort singleton
26
+ shellwords benchmark base64 did_you_mean
27
+ monitor mutex_m timeout
28
+ open3 erb etc ipaddr bigdecimal bigdecimal-math
29
+ prettyprint random-formatter time open-uri resolv
30
+ csv pstore objspace io-console cgi cgi-escape
31
+ strscan
32
+ prism rbs
33
+ ].freeze
34
+ end
35
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ class Environment
7
+ # ADR-82 WD9 — maps a top-level constant name to the locked, RBS-less gem that declares it, so an
8
+ # unresolved constant read (`Faraday`, `Sidekiq`) can carry the `external_gem_without_rbs` provenance
9
+ # cause instead of the generic `unsupported_syntax`. For a gem with no RBS the constant read is where the
10
+ # class name is last visible — by dispatch time the receiver is already `Dynamic[top]` — so this index is
11
+ # what lets `coverage --protection` route those holes to `add_rbs` honestly.
12
+ #
13
+ # Ownership is established by READING, never by guessing: each gem's conventional entry file
14
+ # (`lib/<name>.rb`, with the dash → directory variant) is parsed with Prism and its top-level
15
+ # class / module / constant declarations recorded under their root name. No gem code runs — the same
16
+ # posture as ADR-72's "loads RBS data only" and the ADR-10 walker. A name-derivation heuristic
17
+ # (`faraday` → `Faraday` by camelizing) is deliberately NOT used: it breaks on irregular names
18
+ # (`activesupport` → `ActiveSupport`) and is exactly the guessing the ADR-82 honesty criterion forbids.
19
+ #
20
+ # Everything fails OPEN to "no owner": a gem that is not installed, an entry file that does not exist or
21
+ # does not parse, a root constant declared only in a deeper file. An unindexed constant keeps today's
22
+ # generic cause — the failure mode is a missing label, never a wrong one.
23
+ #
24
+ # The scan is bounded to entry files (one or two per gem) so building the index over a real app's
25
+ # hundreds of RBS-less gems stays sub-second; it is built lazily on the first unresolved constant, so a
26
+ # project whose constants all resolve never pays it.
27
+ #
28
+ # **Gem-directory resolution.** Rigor runs under its OWN bundle (`BUNDLE_GEMFILE=<rigor>/Gemfile`), so
29
+ # `Gem::Specification.find_by_name` sees rigor's gems, not the target project's — it would resolve only
30
+ # the handful of gems both bundles happen to share (i18n, rack) and miss every project-specific gem
31
+ # (the very ones a Rails app's holes root at). So the primary resolver is the **target's bundle install
32
+ # tree** (`<bundle>/ruby/*/gems/<name>-<version>/`, the same pure-filesystem layout
33
+ # {BundleSigDiscovery} walks — no `Bundler` API, no gem code). `Gem::Specification` remains a last-resort
34
+ # fallback for a project installed against system gems with no discoverable bundle; reading a gem's own
35
+ # entry file for its own top-level namespace constant is version-stable, so even a rigor-vs-target
36
+ # version skew on a shared gem yields the same constant name.
37
+ module MissingGemConstantIndex
38
+ module_function
39
+
40
+ # @param gems [Enumerable<Array(String, String)>] `[gem_name, version]` pairs (the `:missing` rows of
41
+ # {RbsCoverageReport}).
42
+ # @param bundle_path [String, Pathname, nil] the target's resolved bundler install root, or nil.
43
+ # @param spec_resolver [#call] `(name, version) -> String?` fallback dir resolver. Injectable for
44
+ # specs; the default is the RubyGems-metadata lookup (no code load).
45
+ # @return [Hash{String => String}] frozen `root constant name => gem name`. On a collision (two gems
46
+ # declaring the same top-level constant) the first gem wins — the CAUSE recorded downstream
47
+ # (external gem without RBS) is true under either owner.
48
+ def build(gems, bundle_path: nil, spec_resolver: method(:installed_gem_dir))
49
+ bundle_dirs = bundle_gem_dirs(bundle_path)
50
+ index = {}
51
+ gems.each do |gem_name, version|
52
+ dir = bundle_dirs["#{gem_name}-#{version}"] || spec_resolver.call(gem_name, version)
53
+ next unless dir
54
+
55
+ entry_files(dir, gem_name).each do |file|
56
+ top_level_root_constants(file).each { |name| index[name] ||= gem_name }
57
+ end
58
+ end
59
+ index.freeze
60
+ end
61
+
62
+ # `{"<name>-<version>" => gem_dir}` for the target's bundle, or `{}` when no bundle is resolvable. The
63
+ # glob mirrors {BundleSigDiscovery}: `<bundle>/ruby/X.Y.Z/gems/<name>-<version>/`. Keyed on the dir
64
+ # basename so a platform-tagged variant (`ffi-1.17.4-aarch64-linux-gnu`) simply doesn't match a
65
+ # `<name>-<version>` lookup — those gems ship native code, not the pure-Ruby constants this indexes.
66
+ def bundle_gem_dirs(bundle_path)
67
+ return {} if bundle_path.nil?
68
+
69
+ base = Pathname.new(bundle_path)
70
+ return {} unless base.directory?
71
+
72
+ Dir.glob(base.join("ruby", "*", "gems", "*")).each_with_object({}) do |dir, acc|
73
+ acc[File.basename(dir)] ||= dir
74
+ end
75
+ end
76
+
77
+ # RubyGems spec metadata lookup — the gem's on-disk source root, without loading any of its code.
78
+ # Exact-version first, any-version fallback. See the class note on why this is only a fallback.
79
+ def installed_gem_dir(name, version)
80
+ spec = begin
81
+ Gem::Specification.find_by_name(name, "= #{version}")
82
+ rescue Gem::LoadError
83
+ begin
84
+ Gem::Specification.find_by_name(name)
85
+ rescue Gem::LoadError
86
+ nil
87
+ end
88
+ end
89
+ spec&.full_gem_path
90
+ end
91
+
92
+ # The conventional require targets for a gem name: `lib/foo.rb`, and `lib/foo/bar.rb` for a dashed
93
+ # `foo-bar`. This is the require-name convention Bundler.require depends on — a filename convention,
94
+ # not a constant-name guess; the constants come from parsing whichever of these exists.
95
+ def entry_files(dir, gem_name)
96
+ candidates = ["lib/#{gem_name}.rb"]
97
+ candidates << "lib/#{gem_name.tr('-', '/')}.rb" if gem_name.include?("-")
98
+ candidates.filter_map do |relative|
99
+ path = File.join(dir, relative)
100
+ path if File.file?(path)
101
+ end
102
+ end
103
+
104
+ # Root names of the file's top-level declarations. Only direct children of the program are read — the
105
+ # root constant of a gem's namespace is declared at file top level by construction (`module Faraday`,
106
+ # `class Money::Error < …` roots at `Money`), so no recursion into bodies is needed.
107
+ def top_level_root_constants(path)
108
+ result = Prism.parse(File.read(path))
109
+ return [] unless result.errors.empty?
110
+
111
+ result.value.statements.body.filter_map do |node|
112
+ case node
113
+ when Prism::ClassNode, Prism::ModuleNode then root_segment(node.constant_path)
114
+ when Prism::ConstantWriteNode then node.name.to_s
115
+ end
116
+ end
117
+ rescue StandardError
118
+ []
119
+ end
120
+
121
+ def root_segment(node)
122
+ current = node
123
+ current = current.parent while current.is_a?(Prism::ConstantPathNode) && current.parent
124
+ current.respond_to?(:name) && current.name ? current.name.to_s : nil
125
+ end
126
+ end
127
+ end
128
+ end