rigortype 0.2.9 → 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 (184) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/docs/handbook/03-narrowing.md +2 -1
  4. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  5. data/docs/handbook/06-classes.md +18 -9
  6. data/docs/handbook/08-understanding-errors.md +1 -0
  7. data/docs/handbook/11-sig-gen.md +11 -1
  8. data/docs/handbook/appendix-mypy.md +4 -3
  9. data/docs/install.md +14 -6
  10. data/docs/manual/01-installation.md +98 -4
  11. data/docs/manual/02-cli-reference.md +49 -12
  12. data/docs/manual/03-configuration.md +34 -2
  13. data/docs/manual/04-diagnostics.md +23 -0
  14. data/docs/manual/07-plugins.md +4 -4
  15. data/docs/manual/09-editor-integration.md +4 -4
  16. data/docs/manual/11-ci.md +51 -0
  17. data/docs/manual/12-caching.md +65 -0
  18. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  19. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  20. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  21. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  22. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  23. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  24. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  25. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  26. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  27. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  28. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  29. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  30. data/lib/rigor/analysis/check_rules.rb +557 -95
  31. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/incremental_session.rb +456 -51
  34. data/lib/rigor/analysis/path_expansion.rb +42 -0
  35. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  36. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  37. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  38. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  39. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  40. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  41. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  42. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  43. data/lib/rigor/analysis/runner.rb +343 -68
  44. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  45. data/lib/rigor/analysis/worker_session.rb +30 -2
  46. data/lib/rigor/bleeding_edge.rb +37 -8
  47. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  48. data/lib/rigor/cache/descriptor.rb +88 -28
  49. data/lib/rigor/cache/file_digest.rb +158 -0
  50. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  51. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  52. data/lib/rigor/cache/store.rb +33 -3
  53. data/lib/rigor/ci_detector.rb +90 -0
  54. data/lib/rigor/cli/annotate_command.rb +15 -9
  55. data/lib/rigor/cli/baseline_command.rb +4 -1
  56. data/lib/rigor/cli/check_command.rb +212 -27
  57. data/lib/rigor/cli/coverage_command.rb +6 -1
  58. data/lib/rigor/cli/docs_command.rb +0 -30
  59. data/lib/rigor/cli/doctor_command.rb +163 -23
  60. data/lib/rigor/cli/lsp_command.rb +5 -0
  61. data/lib/rigor/cli/mcp_command.rb +5 -0
  62. data/lib/rigor/cli/plugins_command.rb +36 -6
  63. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  64. data/lib/rigor/cli/probe_environment.rb +85 -0
  65. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  66. data/lib/rigor/cli/skill_command.rb +1 -32
  67. data/lib/rigor/cli/trace_command.rb +5 -6
  68. data/lib/rigor/cli/type_of_command.rb +9 -8
  69. data/lib/rigor/cli/type_scan_command.rb +8 -8
  70. data/lib/rigor/cli.rb +15 -4
  71. data/lib/rigor/config_audit.rb +48 -2
  72. data/lib/rigor/configuration/severity_profile.rb +31 -0
  73. data/lib/rigor/configuration.rb +156 -8
  74. data/lib/rigor/environment/default_libraries.rb +35 -0
  75. data/lib/rigor/environment/rbs_loader.rb +164 -5
  76. data/lib/rigor/environment.rb +19 -27
  77. data/lib/rigor/inference/budget_trace.rb +77 -2
  78. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  79. data/lib/rigor/inference/def_handle.rb +23 -0
  80. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  81. data/lib/rigor/inference/def_return_typer.rb +2 -1
  82. data/lib/rigor/inference/expression_typer.rb +361 -113
  83. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  84. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  85. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  86. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  87. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  88. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  89. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  90. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  91. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  92. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  93. data/lib/rigor/inference/mutation_widening.rb +42 -8
  94. data/lib/rigor/inference/narrowing.rb +127 -17
  95. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  97. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  98. data/lib/rigor/inference/scope_indexer.rb +451 -36
  99. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  100. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  101. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  102. data/lib/rigor/inference/void_origin.rb +25 -0
  103. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  104. data/lib/rigor/language_server/completion_provider.rb +2 -1
  105. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  106. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  107. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  108. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  109. data/lib/rigor/plugin/base.rb +9 -22
  110. data/lib/rigor/plugin/inflector.rb +12 -3
  111. data/lib/rigor/plugin/io_boundary.rb +5 -1
  112. data/lib/rigor/plugin/isolation.rb +81 -11
  113. data/lib/rigor/plugin/load_error.rb +10 -1
  114. data/lib/rigor/plugin/loader.rb +96 -14
  115. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  116. data/lib/rigor/plugin/registry.rb +32 -23
  117. data/lib/rigor/plugin.rb +26 -0
  118. data/lib/rigor/protection/mutator.rb +3 -2
  119. data/lib/rigor/reflection.rb +64 -0
  120. data/lib/rigor/runtime/jit.rb +128 -0
  121. data/lib/rigor/scope/discovery_index.rb +12 -2
  122. data/lib/rigor/scope.rb +122 -28
  123. data/lib/rigor/sig_gen/classification.rb +5 -1
  124. data/lib/rigor/sig_gen/generator.rb +38 -6
  125. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  126. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  127. data/lib/rigor/sig_gen/renderer.rb +10 -0
  128. data/lib/rigor/sig_gen/write_result.rb +9 -4
  129. data/lib/rigor/sig_gen/writer.rb +24 -2
  130. data/lib/rigor/source/node_children.rb +116 -0
  131. data/lib/rigor/source/node_locator.rb +3 -1
  132. data/lib/rigor/source/node_walker.rb +4 -2
  133. data/lib/rigor/source.rb +1 -0
  134. data/lib/rigor/type/difference.rb +28 -24
  135. data/lib/rigor/type/hash_shape.rb +34 -10
  136. data/lib/rigor/version.rb +1 -1
  137. data/lib/rigortype.rb +24 -0
  138. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  139. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  141. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  142. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  143. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  146. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  147. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  148. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  149. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  150. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  151. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  152. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  153. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  154. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  157. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  158. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  159. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  160. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  162. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  164. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  166. data/sig/prism_node_children.rbs +9 -0
  167. data/sig/rigor/inference.rbs +7 -0
  168. data/sig/rigor/plugin/base.rbs +2 -3
  169. data/sig/rigor/reflection.rbs +1 -0
  170. data/sig/rigor/scope.rbs +12 -1
  171. data/skills/rigor-ci-setup/SKILL.md +10 -0
  172. data/skills/rigor-editor-setup/SKILL.md +11 -0
  173. data/skills/rigor-next-steps/SKILL.md +23 -2
  174. data/skills/rigor-plugin-author/SKILL.md +3 -3
  175. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  176. data/skills/rigor-plugin-review/SKILL.md +2 -2
  177. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  178. data/skills/rigor-project-init/SKILL.md +29 -3
  179. data/skills/rigor-project-init/references/01-detect.md +1 -1
  180. data/skills/rigor-project-init/references/02-configure.md +39 -5
  181. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  182. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  183. metadata +42 -24
  184. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ # ADR-87 WD4 — project file-set expansion shared by the {Analysis::Runner} (miss path) and the
6
+ # boot-slimming {RunCacheProbe}, so both resolve the IDENTICAL analyzed-path SET that feeds the run cache
7
+ # key's `paths` slot. A directory argument globs `**/*.rb` and drops excluded paths; a `.rb` file argument
8
+ # is taken verbatim; anything else contributes no files here (the Runner layers its own path-error
9
+ # diagnostics and buffer / in-memory handling on top — none of which add or remove files for an ordinary
10
+ # on-disk check, so the two file sets agree).
11
+ module PathExpansion
12
+ RUBY_GLOB = "**/*.rb"
13
+
14
+ module_function
15
+
16
+ # The `.rb` files an ordinary (non-buffer) check over `paths` would analyze.
17
+ def ruby_files(paths, exclude_patterns)
18
+ Array(paths).flat_map do |path|
19
+ if File.directory?(path)
20
+ directory_files(path, exclude_patterns)
21
+ elsif File.file?(path) && path.end_with?(".rb")
22
+ [path]
23
+ else
24
+ []
25
+ end
26
+ end
27
+ end
28
+
29
+ def directory_files(dir, exclude_patterns)
30
+ reject_excluded(Dir.glob(File.join(dir, RUBY_GLOB)), exclude_patterns)
31
+ end
32
+
33
+ # `exclude_patterns` are matched with `File.fnmatch?` WITHOUT `FNM_PATHNAME`, so `**`/`*` span separators
34
+ # (the patterns behave like substring globs) — the exact semantics the Runner's directory glob applies.
35
+ def reject_excluded(file_list, exclude_patterns)
36
+ return file_list if exclude_patterns.empty?
37
+
38
+ file_list.reject { |path| exclude_patterns.any? { |pattern| File.fnmatch?(pattern, path) } }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "runner"
5
+
6
+ module Rigor
7
+ module Analysis
8
+ # ADR-88 WD1 — a stable fingerprint of the plugin FACT SURFACE for an incremental run: the plugin-computed
9
+ # values a cached per-file diagnostic can depend on but that the snapshot's global fingerprint (config /
10
+ # gems / RBS env / `signature_paths:`) does NOT capture.
11
+ #
12
+ # The gap: a plugin like `rigor-sorbet` reads `.rb` / `.rbi` sig files under its own `paths:` / `rbi_paths:`
13
+ # (outside `signature_paths:`), builds a catalog, and contributes `dynamic_return` types at call sites in
14
+ # OTHER files. An edit to such a sig file changes the types those call sites resolve WITHOUT moving any
15
+ # analyzed file's content — so `IncrementalSnapshot.fingerprint` stays fresh, the recheck sees an empty
16
+ # changed set, and it serves the stale cached diagnostics. Nothing in the ADR-46 dependency graph records
17
+ # the cross-file plugin read (the catalog is plugin-internal, not a `Scope` table).
18
+ #
19
+ # This class digests three fact-surface channels after plugin `#prepare`:
20
+ # (a) every ADR-9 fact-store publication `(plugin_id, name) -> value` (a producer/consumer fact such as
21
+ # `activerecord :model_index`, `dry-types :dry_type_aliases`),
22
+ # (b) every declared ADR-60 producer's computed value (`sorbet :catalog`, `actionpack :controller_index`),
23
+ # (c) each plugin's optional {Plugin::Base#incremental_state_fingerprint} — a hook for internal catalog
24
+ # state that lives in neither (a) nor (b).
25
+ #
26
+ # The digest rides the {Cache::IncrementalSnapshot} (schema 9); a warm recheck compares it and, on a
27
+ # mismatch, invalidates the snapshot and runs a full analysis (the conservative, sound direction).
28
+ #
29
+ # OPAQUE plugins: a plugin that CONTRIBUTES per-call types (`dynamic_return` / `narrowing_facts`) but
30
+ # declares NONE of (a)/(b)/(c) has stale-able state the fingerprint cannot see. Rather than silently risk a
31
+ # stale reuse, such a plugin makes the snapshot un-reusable (a full analysis every run) and is named in a
32
+ # one-line note. The bundled contributing plugins all declare a surface (producers, facts, or the hook), so
33
+ # the bundled set stays incremental-capable; a third-party plugin that contributes types must do the same.
34
+ #
35
+ # The probe is ALWAYS sequential and independent of the analysis's pool mode, so the invalidation decision
36
+ # is identical whether the recheck ran pooled or sequential (the pooled-vs-sequential parity requirement).
37
+ class PluginFactFingerprint
38
+ # `digest` is nil when no plugins are loaded — the fast path (nothing to fingerprint), which reuses
39
+ # freely. `opaque_plugin_ids` names contributing-but-surfaceless plugins (see the class doc); when
40
+ # non-empty the snapshot is never reused.
41
+ Result = Data.define(:digest, :opaque_plugin_ids) do
42
+ def opaque?
43
+ !opaque_plugin_ids.empty?
44
+ end
45
+
46
+ # A warm recheck may reuse the snapshot only when this run is not opaque and its fact-surface digest
47
+ # matches the one the snapshot stored. A nil `stored` (a pre-schema-9 snapshot, or a snapshot written
48
+ # before any plugin existed) never matches a computed digest — the conservative full-run direction —
49
+ # while a nil==nil (plugin-free project both times) reuses.
50
+ def reusable_against?(stored)
51
+ !opaque? && digest == stored
52
+ end
53
+ end
54
+
55
+ # The SEQUENTIAL probe: load the plugins and run `#prepare` fresh, then fingerprint. Pool-independent
56
+ # (its prepare pass is always sequential), so it is the pooled-mode path and the reference the parity
57
+ # spec asserts against.
58
+ def self.compute(configuration:, cache_store:, plugin_requirer: nil)
59
+ new.digest_registry(
60
+ prepared_registry(configuration: configuration, cache_store: cache_store, plugin_requirer: plugin_requirer)
61
+ )
62
+ end
63
+
64
+ # ADR-88 WD1 — the CHEAP post-hoc path: fingerprint a registry the analysis runner ALREADY prepared
65
+ # (sequential runs run `#prepare` and consult the producers during analysis, so producer values are
66
+ # memoised and their cache entries current). Avoids a second `#prepare` pass and a second producer
67
+ # validation. Used only when the runner's main-process registry is prepared (sequential); the pooled path
68
+ # (whose main process skips `#prepare`) falls back to {.compute}. Both compute the identical digest for a
69
+ # given fact surface, so the reuse decision is pool-independent.
70
+ def self.from_registry(registry)
71
+ new.digest_registry(registry)
72
+ end
73
+
74
+ # Loads the plugins and runs every `#prepare` hook sequentially, returning the prepared registry (nil on
75
+ # any failure → the caller treats it as "no fact surface").
76
+ def self.prepared_registry(configuration:, cache_store:, plugin_requirer:)
77
+ Runner::ProjectPrePasses.new(
78
+ configuration: configuration, cache_store: cache_store, buffer: nil,
79
+ plugin_requirer: plugin_requirer, pool_mode: -> { false }
80
+ ).prepared_registry
81
+ rescue StandardError
82
+ nil
83
+ end
84
+
85
+ def digest_registry(registry)
86
+ return Result.new(digest: nil, opaque_plugin_ids: [].freeze) if registry.nil? || registry.empty?
87
+
88
+ facts_by_plugin = facts_by_plugin(registry.plugins.first&.services&.fact_store)
89
+ parts = fact_parts(facts_by_plugin)
90
+ opaque = []
91
+ registry.plugins.each { |plugin| collect_plugin_parts(plugin, facts_by_plugin, parts, opaque) }
92
+ # Sort the parts so the digest is independent of plugin registration / iteration order.
93
+ Result.new(
94
+ digest: Digest::SHA256.hexdigest(parts.sort.join("\x00")),
95
+ opaque_plugin_ids: opaque.uniq.freeze
96
+ )
97
+ end
98
+
99
+ private
100
+
101
+ # Channel (a) parts: one `fact\x1f<plugin>\x1f<name=digest,...>` string per publishing plugin.
102
+ def fact_parts(facts_by_plugin)
103
+ facts_by_plugin.map { |plugin_id, entries| "fact\x1f#{plugin_id}\x1f#{entries.sort.join("\x1e")}" }
104
+ end
105
+
106
+ # Appends one plugin's producer / hook parts to `parts` and marks it opaque when it contributes a type
107
+ # (`dynamic_return` / `narrowing_facts`) yet declares NONE of (a)/(b)/(c).
108
+ def collect_plugin_parts(plugin, facts_by_plugin, parts, opaque)
109
+ id = safe_id(plugin)
110
+ has_facts = facts_by_plugin.key?(id)
111
+ has_producers = collect_producer_parts(plugin, id, parts, opaque)
112
+ has_hook = collect_hook_part(plugin, id, parts, opaque)
113
+ surface = has_facts || has_producers || has_hook
114
+ opaque << id if !surface && contributes_types?(plugin)
115
+ end
116
+
117
+ # `{ plugin_id => ["name=digest", ...] }` for every ADR-9 published fact (channel a). A plugin that
118
+ # published nothing does not appear.
119
+ def facts_by_plugin(fact_store)
120
+ result = Hash.new { |hash, key| hash[key] = [] }
121
+ return result if fact_store.nil?
122
+
123
+ fact_store.each_fact do |fact|
124
+ result[fact.plugin_id.to_s] << "#{fact.name}=#{digest_value(fact.value)}"
125
+ end
126
+ result
127
+ rescue StandardError
128
+ # An unreadable fact store contributes nothing; opacity for contributing plugins is decided below.
129
+ Hash.new { |hash, key| hash[key] = [] }
130
+ end
131
+
132
+ # Channel (b): a stable signature of every declared producer's VALUE — a digest of the producer's own
133
+ # value, NOT its cache entry blob. The distinction is load-bearing: the blob also carries the dependency
134
+ # descriptor (the input files' digests), so it changes on ANY input edit — including a value-PRESERVING
135
+ # one (editing a controller's body without adding an action leaves `controller_index` identical but
136
+ # rewrites its blob). Digesting the value invalidates the snapshot only when a producer's contributed
137
+ # value actually moves (a new action, a changed sig), which is what a cached diagnostic can depend on;
138
+ # a value-preserving edit keeps the recheck incremental. Returns true when the plugin declared at least
139
+ # one producer (a surface); a producer whose value cannot be digested marks the plugin opaque.
140
+ # rubocop:disable Naming/PredicateMethod -- reports has-surface AND appends parts (a side-effecting builder)
141
+ def collect_producer_parts(plugin, id, parts, opaque)
142
+ producer_ids = plugin.class.producers.keys
143
+ return false if producer_ids.empty?
144
+
145
+ producer_ids.sort.each do |producer_id|
146
+ parts << "prod\x1f#{id}\x1f#{producer_id}\x1f#{digest_value(plugin.producer_value(producer_id))}"
147
+ rescue StandardError
148
+ opaque << id
149
+ end
150
+ true
151
+ end
152
+ # rubocop:enable Naming/PredicateMethod
153
+
154
+ # Channel (c): the optional {Plugin::Base#incremental_state_fingerprint} hook. Returns true when the
155
+ # plugin defines it (it has a surface).
156
+ def collect_hook_part(plugin, id, parts, opaque)
157
+ return false unless plugin.respond_to?(:incremental_state_fingerprint)
158
+
159
+ parts << "hook\x1f#{id}\x1f#{digest_value(plugin.incremental_state_fingerprint)}"
160
+ true
161
+ rescue StandardError
162
+ opaque << id
163
+ true
164
+ end
165
+
166
+ # A stable content digest of an arbitrary fact / producer value. Producer values are Marshal-clean by
167
+ # contract (the ADR-45 disk cache serialises them the same way), so this is the same round-trip the cache
168
+ # already relies on. A Marshal failure raises to the caller, which marks the plugin opaque (never a
169
+ # silent wrong-value digest).
170
+ def digest_value(value)
171
+ Digest::SHA256.hexdigest(Marshal.dump(value))
172
+ end
173
+
174
+ def contributes_types?(plugin)
175
+ klass = plugin.class
176
+ klass.dynamic_returns.any? || klass.narrowing_facts_rules.any?
177
+ rescue StandardError
178
+ false
179
+ end
180
+
181
+ def safe_id(plugin)
182
+ plugin.manifest.id.to_s
183
+ rescue StandardError
184
+ plugin.class.name.to_s
185
+ end
186
+ end
187
+ end
188
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "check_rules"
3
+ require_relative "check_rules/rule_ids"
4
4
 
5
5
  module Rigor
6
6
  module Analysis
@@ -215,6 +215,40 @@ module Rigor
215
215
  since: "0.0.2"
216
216
  ),
217
217
 
218
+ CheckRules::RULE_RAISE_NON_EXCEPTION => Entry.new(
219
+ id: CheckRules::RULE_RAISE_NON_EXCEPTION,
220
+ summary: "`raise` / `fail` operand provably cannot be raised (TypeError at runtime).",
221
+ fires_when: [
222
+ "The call is an implicit-self `raise x` / `fail x` (Kernel#raise) with a plain first " \
223
+ "positional argument.",
224
+ "The argument's inferred type is concrete and provably NOT a legal raise operand: not an " \
225
+ "Exception class, not an Exception instance, not a String, and its class defines no " \
226
+ "`#exception` (e.g. `raise 42`, `raise :sym`, `raise [1, 2]`, `raise nil` — an explicit " \
227
+ "nil argument is a TypeError, unlike bare `raise`).",
228
+ "A Class-object operand fires only when the class is RBS-known and its ancestry provably " \
229
+ "excludes Exception (including a concretely-resolved bare Module).",
230
+ "A union argument fires only when EVERY arm is independently illegal."
231
+ ],
232
+ does_not_fire_when: [
233
+ "The argument is `Dynamic[T]` / untyped / unresolved, or a union with any legal or unknown arm.",
234
+ "The call has an explicit receiver (`obj.raise(...)` is a user method, not Kernel#raise).",
235
+ "The project redefines `raise` / `fail` anywhere the call could resolve (toplevel def, " \
236
+ "Object/Kernel monkey-patch, or a def on the enclosing class — in source or `pre_eval:`).",
237
+ "The argument's class defines `#exception` (RBS-declared or project-defined) — the duck " \
238
+ "protocol `raise` consults at runtime.",
239
+ "The instance operand's nominal class subsumes exceptions (`Object`, `Class`, `Module`, a " \
240
+ "module type) — the runtime value may still be a legal operand.",
241
+ "The operand's class is declared in the analyzed source — a project `sig/` that omits the " \
242
+ "superclass would misread the ancestry as non-Exception, so source-declared classes stay silent.",
243
+ "Bare `raise` (re-raises `$!`), or a splat / keyword / forwarded first argument."
244
+ ],
245
+ suppression: "`# rigor:disable call.raise-non-exception`.",
246
+ severity_authored: :error,
247
+ severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
248
+ evidence_tier: :high,
249
+ since: "0.3.0"
250
+ ),
251
+
218
252
  CheckRules::RULE_DUMP_TYPE => Entry.new(
219
253
  id: CheckRules::RULE_DUMP_TYPE,
220
254
  summary: "`dump_type(expr)` from Rigor::Testing — informational type print.",
@@ -346,6 +380,63 @@ module Rigor
346
380
  since: "0.1.17"
347
381
  ),
348
382
 
383
+ CheckRules::RULE_RETURN_IN_ENSURE => Entry.new(
384
+ id: CheckRules::RULE_RETURN_IN_ENSURE,
385
+ summary: "Explicit `return` inside an `ensure` clause swallows in-flight exceptions.",
386
+ fires_when: [
387
+ "An explicit `return` sits lexically inside the `ensure` clause of a `begin` / `def` / class " \
388
+ "body (`ensure` always runs, so its `return` overrides the method's in-flight return value " \
389
+ "AND silently discards any exception being raised).",
390
+ "The `return` is inside a plain block (`each do ... return ... end`) within the ensure body — " \
391
+ "a `return` there still exits the enclosing method."
392
+ ],
393
+ does_not_fire_when: [
394
+ "The `return` is inside a nested `def`, a lambda (`->` / `lambda`), or a `define_method` " \
395
+ "block within the ensure body — it exits that inner frame, not the one the `ensure` guards.",
396
+ "The `ensure` body contains no explicit `return` (implicit last-expression values in `ensure` " \
397
+ "are discarded harmlessly and do not swallow exceptions)."
398
+ ],
399
+ suppression: "`# rigor:disable flow.return-in-ensure` on the `return` line.",
400
+ severity_authored: :warning,
401
+ severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
402
+ # Purely syntactic proof with a frame-aware envelope — Ruby's `ensure` semantics make every
403
+ # firing a real control-flow hazard (RuboCop `Lint/EnsureReturn` precedent, low-FP).
404
+ evidence_tier: :high,
405
+ since: "0.3.0"
406
+ ),
407
+
408
+ CheckRules::RULE_SHADOWED_RESCUE_CLAUSE => Entry.new(
409
+ id: CheckRules::RULE_SHADOWED_RESCUE_CLAUSE,
410
+ summary: "A `rescue` clause an earlier clause of the same chain already fully catches.",
411
+ fires_when: [
412
+ "An earlier `rescue` clause in the same `begin` / `def` rescue chain names a superclass (or " \
413
+ "the same class) of EVERY exception class the later clause names — `rescue StandardError` " \
414
+ "followed by `rescue ArgumentError` leaves the ArgumentError arm dead.",
415
+ "A bare `rescue` / `rescue => e` counts as the implicit `StandardError` on either side.",
416
+ "Every exception reference in both clauses is a constant path resolving (against its lexical " \
417
+ "namespace) to a CLASS with known ancestry — RBS / registry core classes, or a project class " \
418
+ "whose `class Foo < Bar` superclass chain is discovered.",
419
+ "A multi-class arm (`rescue A, B`) fires only when every class it names is covered by earlier " \
420
+ "clauses."
421
+ ],
422
+ does_not_fire_when: [
423
+ "Any exception reference in either clause is an unresolved constant, a dynamic expression " \
424
+ "(`rescue klass_var`), or a splat (`rescue *ERRORS`) — the comparisons involving that clause " \
425
+ "stay silent.",
426
+ "A resolved constant is a MODULE (the `rescue MyGem::Error` module-tag mixin pattern — module " \
427
+ "`===` semantics are custom), or a project constant the discovery cannot certify as a class.",
428
+ "The later clause names a SUPERCLASS of an earlier one — the normal narrow-to-wide rescue order.",
429
+ "The clauses belong to different (nested) `begin` nodes — only clauses of the same chain compare."
430
+ ],
431
+ suppression: "`# rigor:disable shadowed-rescue-clause` on the shadowed `rescue` line.",
432
+ severity_authored: :warning,
433
+ severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
434
+ # Purely syntactic + class-ancestry proof: both sides must resolve to concrete classes with known
435
+ # ancestry before a comparison happens, so a firing has no flow / mutation FP envelope.
436
+ evidence_tier: :high,
437
+ since: "0.3.0"
438
+ ),
439
+
349
440
  CheckRules::RULE_DEAD_ASSIGNMENT => Entry.new(
350
441
  id: CheckRules::RULE_DEAD_ASSIGNMENT,
351
442
  summary: "Local variable assigned in a method body but never read.",
@@ -370,6 +461,33 @@ module Rigor
370
461
  since: "0.1.2"
371
462
  ),
372
463
 
464
+ CheckRules::RULE_DUPLICATE_HASH_KEY => Entry.new(
465
+ id: CheckRules::RULE_DUPLICATE_HASH_KEY,
466
+ summary: "Duplicate literal key within a single Hash literal (the last entry wins silently at runtime).",
467
+ fires_when: [
468
+ "Two entries of one Hash literal — braced (`{ a: 1, a: 2 }`) or bare keyword arguments " \
469
+ "(`m(a: 1, a: 2)`) — carry the same value-pinned literal key: a symbol (the `key:` shorthand " \
470
+ "and `:key =>` spell the same symbol), a plain non-interpolated string, an integer, a float, " \
471
+ "or `true` / `false` / `nil`.",
472
+ "A `**splat` between two identical literal keys does not rescue the pair — the later literal " \
473
+ "entry still overwrites the earlier one regardless of what the splat contributes."
474
+ ],
475
+ does_not_fire_when: [
476
+ "Either key is not value-pinned at parse time: interpolated strings / symbols, constants, " \
477
+ "method calls, locals, and `**splat` entries are never compared.",
478
+ "The keys live in different literal kinds — `:a` vs `\"a\"`, and `1` vs `1.0` (`1.eql?(1.0)` " \
479
+ "is false, so Hash treats them as distinct keys) never collide.",
480
+ "The repeated keys sit in different Hash literals (nested literals are each their own scope)."
481
+ ],
482
+ suppression: "`# rigor:disable duplicate-hash-key` on the later occurrence's line.",
483
+ severity_authored: :warning,
484
+ severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
485
+ # Purely syntactic value-pinned comparison with no metaprogramming escape: when it fires, the
486
+ # runtime overwrite is certain (Ruby itself warns under `-w`).
487
+ evidence_tier: :high,
488
+ since: "0.3.0"
489
+ ),
490
+
373
491
  CheckRules::RULE_RETURN_TYPE => Entry.new(
374
492
  id: CheckRules::RULE_RETURN_TYPE,
375
493
  summary: "Method body's last-expression type is incompatible with the declared return type.",
@@ -526,6 +644,113 @@ module Rigor
526
644
  # escapes are excluded.
527
645
  evidence_tier: :high,
528
646
  since: "0.1.2"
647
+ ),
648
+
649
+ CheckRules::RULE_SUPPRESSION_UNKNOWN_RULE => Entry.new(
650
+ id: CheckRules::RULE_SUPPRESSION_UNKNOWN_RULE,
651
+ summary: "A `# rigor:disable[-file]` comment names a rule that does not exist.",
652
+ fires_when: [
653
+ "A `# rigor:disable` / `# rigor:disable-file` marker carries a token that is not a canonical " \
654
+ "rule id, a legacy alias, `all`, or a family wildcard (`call` / `flow` / ...).",
655
+ "The token is also not a known non-catalogue engine diagnostic (`rbs_extended.*`, `dynamic.*`, " \
656
+ "`rbs.*`, `pre-eval.*`, or a bare engine id such as `load-error`).",
657
+ "Typically a typo — `call.undefined-metod` — leaving the suppression silently ineffective."
658
+ ],
659
+ does_not_fire_when: [
660
+ "The token resolves (canonical id, legacy alias, `all`, family wildcard, known engine id).",
661
+ "The token starts with `plugin.` — plugins load dynamically, so their rule vocabulary cannot " \
662
+ "be enumerated statically and under-warning is the FP-safe direction.",
663
+ "The comment merely mentions the marker followed by non-token text (documentation prose " \
664
+ "like \"`# rigor:disable <rule>` comments\") — that is not parsed as a suppression either."
665
+ ],
666
+ suppression: "Fix or remove the dead token; `# rigor:disable suppression.unknown-rule` on the " \
667
+ "same line, or `disable: [\"suppression.unknown-rule\"]` in `.rigor.yml`.",
668
+ severity_authored: :warning,
669
+ severity_by_profile: { lenient: :warning, balanced: :warning, strict: :warning },
670
+ # Pure token-table membership over the same tables the suppression matcher uses — no inference
671
+ # uncertainty; the plugin/prose escapes above are excluded before firing.
672
+ evidence_tier: :high,
673
+ since: "0.3.0"
674
+ ),
675
+
676
+ CheckRules::RULE_SUPPRESSION_EMPTY => Entry.new(
677
+ id: CheckRules::RULE_SUPPRESSION_EMPTY,
678
+ summary: "A `# rigor:disable[-file]` comment lists no rules.",
679
+ fires_when: [
680
+ "A comment is exactly the bare marker (`# rigor:disable` / `# rigor:disable-file`) with " \
681
+ "nothing but whitespace or commas after it.",
682
+ "Such a marker suppresses nothing — the author almost certainly meant to name rules or `all`."
683
+ ],
684
+ does_not_fire_when: [
685
+ "At least one token follows the marker (each token is then checked by " \
686
+ "`suppression.unknown-rule` instead).",
687
+ "Non-token text follows the marker (documentation prose mentioning the syntax)."
688
+ ],
689
+ suppression: "Complete the marker (`# rigor:disable <rule>` / `all`) or delete it; " \
690
+ "`disable: [\"suppression.empty\"]` in `.rigor.yml`.",
691
+ severity_authored: :warning,
692
+ severity_by_profile: { lenient: :warning, balanced: :warning, strict: :warning },
693
+ # Syntactic: the marker word is present and the token list is provably empty.
694
+ evidence_tier: :high,
695
+ since: "0.3.0"
696
+ ),
697
+
698
+ CheckRules::RULE_VALUE_USE_VOID => Entry.new(
699
+ id: CheckRules::RULE_VALUE_USE_VOID,
700
+ summary: "A value recovered from an author-declared `-> void` return is used in value context.",
701
+ fires_when: [
702
+ "A call resolves through direct RBS dispatch (the receiver's own resolvable class) to a method " \
703
+ "whose selected overload declares a `-> void` return.",
704
+ "That call node sits in a value position: an assignment right-hand side (`x = obj.log(...)`), a " \
705
+ "call's explicit receiver (`obj.log(...).inspect`), or a call's positional argument.",
706
+ "The `use-of-void-value` bleeding-edge feature is enabled (the rule resolves `:off` otherwise)."
707
+ ],
708
+ does_not_fire_when: [
709
+ "The void call is a bare statement (statement context) — a `void` result is accepted there.",
710
+ "The value is a legitimate `top` (an author-declared `-> top` return) or any non-void type — " \
711
+ "only an author-declared `-> void` is recorded, so a real `top` never fires.",
712
+ "The `-> void` return was resolved through the Object / user-class ancestor fallback rather than " \
713
+ "direct dispatch (the transitive / ancestor case is deferred, ADR-100 WD4).",
714
+ "The `use-of-void-value` bleeding-edge feature is not enabled (the default)."
715
+ ],
716
+ suppression: "`# rigor:disable static.value-use.void` on the call line, or " \
717
+ "`disable: [\"static.value-use.void\"]` in `.rigor.yml`.",
718
+ severity_authored: :warning,
719
+ severity_by_profile: { lenient: :off, balanced: :off, strict: :off },
720
+ # Recorded only for an author-written `-> void` on the direct-dispatch path — the strongest
721
+ # possible "do not rely on this return" signal — so a firing is a concrete misuse, not an
722
+ # inference guess. It ships `:off` behind `bleeding_edge:` per ADR-50 WD1 (a new required
723
+ # diagnostic is a compatibility change), not for lack of confidence.
724
+ evidence_tier: :high,
725
+ since: "0.3.0"
726
+ ),
727
+
728
+ CheckRules::RULE_SUPPRESSION_UNKNOWN_MARKER => Entry.new(
729
+ id: CheckRules::RULE_SUPPRESSION_UNKNOWN_MARKER,
730
+ summary: "A comment uses a suppression marker Rigor does not recognise " \
731
+ "(`rigor:disable-next-line`, `rigor:enable`, ...).",
732
+ fires_when: [
733
+ "A comment carries `rigor:disable-<suffix>` with a suffix other than `file`, or " \
734
+ "`rigor:enable[-<suffix>]` — typically the RuboCop reflex `# rigor:disable-next-line " \
735
+ "<rule>` — followed by nothing or a rule-list-shaped remainder.",
736
+ "Such a marker is invisible to the whole suppression grammar, so it silently suppresses " \
737
+ "nothing; Rigor's only markers are `# rigor:disable <rules>` (same line) and " \
738
+ "`# rigor:disable-file <rules>`."
739
+ ],
740
+ does_not_fire_when: [
741
+ "The marker is one of the two recognised forms (their tokens are then checked by " \
742
+ "`suppression.unknown-rule` / `suppression.empty` instead).",
743
+ "Non-token text follows the marker (documentation prose mentioning the spelling)."
744
+ ],
745
+ suppression: "Rewrite as `# rigor:disable <rules>` on the offending line (Rigor has no " \
746
+ "next-line or enable form) or delete the comment; " \
747
+ "`disable: [\"suppression.unknown-marker\"]` in `.rigor.yml`.",
748
+ severity_authored: :warning,
749
+ severity_by_profile: { lenient: :warning, balanced: :warning, strict: :warning },
750
+ # Syntactic: the marker word is present and provably outside the suppression grammar; the
751
+ # prose escape is excluded before firing.
752
+ evidence_tier: :high,
753
+ since: "0.3.0"
529
754
  )
530
755
  }.freeze
531
756
 
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ # ADR-87 WD4 — the `rbs` gem's version feeds the cache key's `gems` slot (`RbsDescriptor.rbs_gem_entry`).
5
+ # Load only its version constant (not the full RBS parser / env) so the boot-slimming probe can build the key
6
+ # without paying for — or `$LOADED_FEATURES`-touching — the RBS machinery. Falls back to the full gem only if
7
+ # this build of `rbs` has no standalone version file.
8
+ begin
9
+ require "rbs/version"
10
+ rescue LoadError
11
+ require "rbs"
12
+ end
13
+
14
+ require_relative "../version"
15
+ require_relative "../cache/descriptor"
16
+ require_relative "../cache/rbs_descriptor"
17
+ require_relative "../environment/default_libraries"
18
+
19
+ module Rigor
20
+ module Analysis
21
+ # ADR-45 / ADR-87 WD4 — the stable run-result cache KEY, built in ONE place so the miss path (the
22
+ # {Analysis::Runner}) and the boot-slimming hit path (the {RunCacheProbe}) can never drift out of key
23
+ # agreement. The key reads the stable inputs known before analysis: the `rbs` gem version, the resolved
24
+ # RBS library list, a digest of the whole resolved configuration, the engine + schema + `--explain`
25
+ # triple, and the analyzed-path SET.
26
+ #
27
+ # The ONLY difference between the two callers is the `rbs_config_entries` slot: the Runner passes the
28
+ # loader's `RbsDescriptor.config_entries` (which include a `rbs.virtual_rbs` entry when a plugin's
29
+ # `source_rbs_synthesizer` contributed one), while the probe passes {#libraries_config_entries} —
30
+ # reconstructed from config alone, WITHOUT building the RBS environment or loading any plugin. A project
31
+ # whose plugins DO synthesise virtual RBS therefore produces a probe key that omits that entry, so the
32
+ # probe simply misses and the full path takes over (sound: never a wrong hit, only a forgone fast lane).
33
+ # Slot order is irrelevant — {Cache::Descriptor#to_canonical_hash} sorts configs by key.
34
+ module RunCacheKey
35
+ module_function
36
+
37
+ RUN_DIAGNOSTICS_PRODUCER_ID = "analysis.run-diagnostics"
38
+
39
+ # @param rbs_config_entries [Array<Cache::Descriptor::ConfigEntry>] the RBS-derived config slots
40
+ # (`rbs.libraries` [+ `rbs.virtual_rbs`]). nil on any failure so a malformed key disables the cache.
41
+ def descriptor(configuration:, files:, explain:, rbs_config_entries:)
42
+ Cache::Descriptor.new(
43
+ gems: [Cache::RbsDescriptor.rbs_gem_entry],
44
+ configs: rbs_config_entries + [
45
+ config_entry("configuration", Marshal.dump(configuration.to_h)),
46
+ config_entry("engine",
47
+ "#{Rigor::VERSION}:#{Cache::Descriptor::SCHEMA_VERSION}:#{explain}"),
48
+ config_entry("paths", files.sort.join("\n"))
49
+ ]
50
+ )
51
+ rescue StandardError
52
+ nil
53
+ end
54
+
55
+ def config_entry(key, payload)
56
+ Cache::Descriptor::ConfigEntry.new(key: key, value_hash: Digest::SHA256.hexdigest(payload))
57
+ end
58
+
59
+ # The `rbs.libraries` config slot reconstructed from configuration alone — byte-identical to the
60
+ # loader's `RbsDescriptor.libraries_entry(loader.libraries)` because `Environment.for_project` merges
61
+ # exactly `DEFAULT_LIBRARIES + config.libraries` (uniq) into `loader.libraries`.
62
+ def libraries_config_entries(configuration)
63
+ merged = (Environment::DEFAULT_LIBRARIES + configuration.libraries.map(&:to_s)).uniq
64
+ [Cache::RbsDescriptor.libraries_entry(merged)]
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "result"
4
+ require_relative "run_cache_key"
5
+ require_relative "path_expansion"
6
+ require_relative "severity_stamp"
7
+ require_relative "../cache/store"
8
+ require_relative "../cache/file_digest"
9
+
10
+ module Rigor
11
+ module Analysis
12
+ # ADR-87 WD4 — the boot-slimming run-cache hit probe. Serves an ordinary `rigor check`'s diagnostics
13
+ # straight from the ADR-45 `analysis.run-diagnostics` cache WITHOUT loading the inference engine, its
14
+ # plugin gems, or building the RBS environment: a warm HIT boots only CLI + config + cache + digest code
15
+ # (the recon's `$LOADED_FEATURES` never gains a `rigor/inference` entry). The full plugin/prepass +
16
+ # env-build tax (~0.8s on a gitlab null) is paid ONLY on a miss.
17
+ #
18
+ # Soundness is inherited wholesale from ADR-45: the stored dependency descriptor records every file the
19
+ # prior run read — analyzed sources, the RBS signature tree, AND every file each plugin read mid-analysis
20
+ # (the Pundit-policy case) — and {Cache::Store#peek_validated} re-checks all of it against the live tree
21
+ # (ADR-87 `:stat`-validated). A hit therefore means every input the prior run observed is unchanged, so
22
+ # its cached diagnostics are exactly what a fresh full run would produce; skipping the plugin prepasses is
23
+ # sound because their inputs are in that same validated set. The key is built through the shared
24
+ # {RunCacheKey} — a project whose plugins synthesise virtual RBS produces a probe key that omits that
25
+ # entry, so it simply misses and the full path takes over (never a wrong hit).
26
+ class RunCacheProbe
27
+ # @param configuration [Rigor::Configuration]
28
+ # @param cache_root [String]
29
+ # @param explain [Boolean] the `--explain` flag (folded into the key, as the runner does).
30
+ def initialize(configuration:, cache_root:, explain:)
31
+ @configuration = configuration
32
+ @cache_root = cache_root
33
+ @explain = explain
34
+ end
35
+
36
+ # @param paths [Array<String>] the analysis roots (`@argv` or `configuration.paths`).
37
+ # @return [Analysis::Result, nil] the cached run result with the severity profile applied and no stats
38
+ # (matching a cache-served `Runner#run`), or nil to DECLINE — a miss / stale / unavailable cache — so
39
+ # the caller loads the engine and runs the full path. Any failure declines rather than raising: the
40
+ # probe must never turn a servable run into a crash.
41
+ def serve(paths)
42
+ files = PathExpansion.ruby_files(paths, @configuration.exclude_patterns)
43
+ key = RunCacheKey.descriptor(
44
+ configuration: @configuration, files: files, explain: @explain,
45
+ rbs_config_entries: RunCacheKey.libraries_config_entries(@configuration)
46
+ )
47
+ return nil if key.nil?
48
+
49
+ diagnostics = validated_diagnostics(key)
50
+ return nil if diagnostics.nil?
51
+
52
+ Result.new(diagnostics: SeverityStamp.apply(diagnostics, @configuration), stats: nil)
53
+ rescue StandardError
54
+ nil
55
+ end
56
+
57
+ private
58
+
59
+ def validated_diagnostics(key)
60
+ store = Cache::Store.new(root: @cache_root, max_bytes: @configuration.cache_max_bytes)
61
+ # The digest fallback + `cache.validation` / RIGOR_STRICT_VALIDATION escape hatch route through the
62
+ # same per-run FileDigest scope the full run uses.
63
+ strict = @configuration.cache_validation_strict?
64
+ Cache::FileDigest.with_run(strict: strict) do
65
+ store.peek_validated(
66
+ producer_id: RunCacheKey::RUN_DIAGNOSTICS_PRODUCER_ID, key_descriptor: key
67
+ )
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end