rigortype 0.3.6 → 0.3.7

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 (213) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +409 -11
  14. data/docs/manual/02-cli-reference.md +16 -7
  15. data/docs/manual/05-inspecting-types.md +20 -8
  16. data/docs/manual/07-plugins.md +4 -0
  17. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  18. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  19. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  20. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  21. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  22. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  23. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  24. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  25. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  26. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  27. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  28. data/lib/rigor/analysis/check_rules.rb +383 -48
  29. data/lib/rigor/analysis/crash_signature.rb +116 -0
  30. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  31. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  32. data/lib/rigor/analysis/incremental.rb +28 -0
  33. data/lib/rigor/analysis/incremental_session.rb +44 -9
  34. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  35. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  36. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  37. data/lib/rigor/analysis/result.rb +32 -0
  38. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  39. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
  40. data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
  41. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  42. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
  43. data/lib/rigor/analysis/runner.rb +147 -10
  44. data/lib/rigor/analysis/worker_session.rb +10 -1
  45. data/lib/rigor/bleeding_edge.rb +7 -6
  46. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  47. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  48. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  49. data/lib/rigor/cache/descriptor.rb +42 -2
  50. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  51. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
  52. data/lib/rigor/cache/store.rb +10 -1
  53. data/lib/rigor/cli/coverage_command.rb +23 -18
  54. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  55. data/lib/rigor/cli/coverage_scan.rb +47 -6
  56. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  57. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  58. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  59. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  60. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  61. data/lib/rigor/cli/sig_gen_command.rb +18 -0
  62. data/lib/rigor/cli/type_of_command.rb +188 -41
  63. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  64. data/lib/rigor/cli/type_scan_command.rb +10 -1
  65. data/lib/rigor/cli.rb +1 -1
  66. data/lib/rigor/configuration.rb +7 -4
  67. data/lib/rigor/effects/plugin_facts.rb +1 -1
  68. data/lib/rigor/effects/scanner.rb +2 -3
  69. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  70. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  71. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  72. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  73. data/lib/rigor/environment/rbs_loader.rb +525 -46
  74. data/lib/rigor/environment.rb +38 -15
  75. data/lib/rigor/inference/acceptance.rb +172 -20
  76. data/lib/rigor/inference/captured_locals.rb +62 -0
  77. data/lib/rigor/inference/content_join.rb +347 -0
  78. data/lib/rigor/inference/def_handle.rb +15 -3
  79. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  80. data/lib/rigor/inference/expression_typer.rb +1020 -141
  81. data/lib/rigor/inference/fork_map.rb +6 -1
  82. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  83. data/lib/rigor/inference/hkt_registry.rb +46 -0
  84. data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
  85. data/lib/rigor/inference/index_write_widening.rb +48 -0
  86. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  87. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  88. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  89. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  92. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  93. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  95. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  96. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  97. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  98. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  99. data/lib/rigor/inference/mutation_widening.rb +244 -174
  100. data/lib/rigor/inference/narrowing.rb +331 -27
  101. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  102. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  103. data/lib/rigor/inference/precision_scanner.rb +5 -2
  104. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  105. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  106. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  107. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  108. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  109. data/lib/rigor/inference/version_guard.rb +229 -0
  110. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  111. data/lib/rigor/plugin/base.rb +3 -2
  112. data/lib/rigor/plugin/inflector.rb +14 -5
  113. data/lib/rigor/plugin/io_boundary.rb +107 -4
  114. data/lib/rigor/plugin/loader.rb +14 -0
  115. data/lib/rigor/protection/analysis_guard.rb +60 -0
  116. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  117. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  118. data/lib/rigor/protection/discovery_seed.rb +1 -1
  119. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  120. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  121. data/lib/rigor/protection/mutator.rb +121 -18
  122. data/lib/rigor/reflection.rb +182 -30
  123. data/lib/rigor/scope/discovery_index.rb +39 -1
  124. data/lib/rigor/scope.rb +385 -4
  125. data/lib/rigor/sig_gen/classification.rb +12 -1
  126. data/lib/rigor/sig_gen/generator.rb +254 -5
  127. data/lib/rigor/sig_gen/renderer.rb +1 -6
  128. data/lib/rigor/sig_gen/writer.rb +3 -0
  129. data/lib/rigor/source/constant_path.rb +79 -2
  130. data/lib/rigor/triage/catalogue.rb +1 -1
  131. data/lib/rigor/type/combinator.rb +10 -0
  132. data/lib/rigor/type/maybe.rb +47 -0
  133. data/lib/rigor/type/refined.rb +1 -2
  134. data/lib/rigor/type/result.rb +53 -0
  135. data/lib/rigor/type.rb +2 -0
  136. data/lib/rigor/version.rb +1 -1
  137. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  138. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  139. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  140. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  141. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  142. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  147. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  148. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  150. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  151. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  152. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  153. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  154. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  156. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  157. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  158. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  159. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  160. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  163. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  164. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  165. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  166. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  167. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  168. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  169. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  170. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  171. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  172. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  173. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  174. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  175. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  176. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  177. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  178. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  179. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  180. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  181. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  182. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  183. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  184. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  185. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  186. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  187. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  188. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  189. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  190. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  191. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  192. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  193. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  194. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  195. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  196. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  197. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  198. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  200. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  201. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  202. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  203. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  204. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  205. data/sig/rigor/analysis/baseline.rbs +1 -1
  206. data/sig/rigor/environment.rbs +3 -0
  207. data/sig/rigor/inference.rbs +4 -1
  208. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  209. data/sig/rigor/reflection.rbs +3 -1
  210. data/sig/rigor/scope.rbs +25 -1
  211. data/sig/rigor/type.rbs +95 -1
  212. data/sig/rigor.rbs +16 -0
  213. metadata +69 -3
@@ -7,7 +7,9 @@ module Rigor
7
7
  # Refined types for predefined Ruby / stdlib constants whose upstream RBS signatures are
8
8
  # broader than the constants' documented runtime invariants.
9
9
  #
10
- # Resolution is two-tiered:
10
+ # Resolution is two-tiered, and **both tiers are closed tables authored in this file**. A name
11
+ # taken from the analysed program is never resolved against the analyzer's own object space —
12
+ # see *Why the table is closed* below.
11
13
  #
12
14
  # **Tier 1 — exact-value whitelist** (`FOLDED_CONSTANTS`):
13
15
  # Constants whose value is bit-for-bit identical across every Ruby version and platform
@@ -23,24 +25,44 @@ module Rigor
23
25
  # and as plain `Integer`s they fall through Tier 2 to the RBS type harmlessly.
24
26
  # `Complex::I` is deferred (no complex-fold consumer).
25
27
  #
26
- # **Tier 2 — runtime String inspection**:
27
- # For any other constant, the module resolves it via `const_get` against the analyzer's
28
- # own Ruby runtime. Core / stdlib constants (e.g. `RUBY_VERSION`, `RUBY_PLATFORM`) are
29
- # always loaded into the analyzer process; project-defined constants are not (they live
30
- # only in ASTs), so their `const_get` raises `NameError` and the lookup falls through to
31
- # the RBS type tier.
28
+ # **Tier 2 — String refinements for a closed set of interpreter constants**
29
+ # (`RUNTIME_STRING_CONSTANTS`): each listed name is read ONCE, at load time, from the analyzer's
30
+ # own runtime and classified by the value found:
32
31
  #
33
- # For a successfully resolved `String` value:
34
- # - empty string → no refinement (fall through to RBS `String`)
32
+ # - not a String, or an empty one → no entry (the name falls through to the RBS type)
35
33
  # - a Ruby numeric literal → `numeric-string`
36
34
  # - non-empty otherwise → `non-empty-string`
37
35
  #
38
- # **Exclusion set** (`RUNTIME_INSPECTION_EXCLUDED`):
39
- # String constants that appear non-empty in the current runtime but are documented to be
40
- # potentially empty in some build configuration or alternative implementation. Exclusions
41
- # are populated by scanning Ruby's C source (version.c, etc.) and RBS comments for any
42
- # constant whose documentation says "may be empty" or "platform-specific default". None
43
- # are known today; the set exists as a safety net.
36
+ # Admission criterion for the list: the constant's value is fixed by the same interpreter choice
37
+ # that already fixes the core / stdlib RBS the engine reads, and it is non-empty in every build.
38
+ # A third-party gem's constant does NOT qualify the analysed project resolves its own copy
39
+ # through its `Gemfile.lock`, which this layer cannot see. This is the criterion
40
+ # `Inference::VersionGuard::VERSION_CONSTANTS` already applies to the constants it folds guards on.
41
+ #
42
+ # **Why the table is closed** ([#680](https://github.com/rigortype/rigor/issues/680)):
43
+ # Tier 2 used to resolve ANY name reaching it — a name read out of the analysed source — with
44
+ # `const_get` against the analyzer's runtime, guarded by `const_defined?(part, false)`. That
45
+ # guard does not answer the question it was written for: `const_defined?` is true for a
46
+ # **registered but not yet triggered autoload**, so `const_get` fired the autoload and the target
47
+ # file was *executed* inside the analyzer. Analysing CRuby's own `lib/prism` reached
48
+ # `prism/translation/ruby_parser.rb`, which calls `exit` at the top level when `sexp_processor`
49
+ # is absent; `SystemExit` is not a `StandardError`, so neither the rescue in this file nor the
50
+ # runner's per-file rescue saw it and the run stopped with no diagnostics and no summary. The
51
+ # target of an autoload is arbitrary — it may exit, raise, print, mutate global state, or take
52
+ # unbounded time — and `rigor check` is routinely pointed at code the user did not write.
53
+ #
54
+ # Closing the table rather than only guarding the walk is what makes that structural: no name
55
+ # from the analysed program reaches `const_get` at all. A census of what the open walk actually
56
+ # bought says the precision cost is nil — over GitLab's `app` + `lib` (11,189 files, 184,965
57
+ # constant references) it answered for five names and ten references (`RUBY_VERSION`,
58
+ # `RUBY_PLATFORM`, `RUBY_DESCRIPTION`, `File::SEPARATOR`, `File::PATH_SEPARATOR`, every one of
59
+ # them listed below), against 17 references that would have autoloaded `ipaddr`; over Mastodon,
60
+ # three names and five references against 43 such. What it answered for *beyond* the list was
61
+ # always a constant that happened to live in **Rigor's own** bundle — `Rigor::VERSION` and
62
+ # `RBS::VERSION` while Rigor checks itself, rubygems and Bundler internals while it checks
63
+ # CRuby's `lib/` — and that is worse than no answer: identical source would type differently
64
+ # depending on how the analyzer was installed. Removing an ad hoc `Object.const_get` from an
65
+ # analysis path is the same move ADR-4 § 330 already made for predicate narrowing.
44
66
  #
45
67
  # This module is consulted by `Environment#constant_for_name` BEFORE the RBS
46
68
  # constant-type table (widest types) but AFTER in-source constant writes (the user's own
@@ -68,70 +90,97 @@ module Rigor
68
90
 
69
91
  # --- tier 2 -------------------------------------------------------
70
92
 
71
- # String constants whose runtime value is non-empty in the current Ruby but that should
72
- # NOT be narrowed because they are documented to be potentially empty in some build or
73
- # implementation.
93
+ # The closed set of names tier 2 may read, by the admission criterion in the module note.
74
94
  #
75
- # Methodology: grep Ruby's version.c and similar C sources, and the RBS comment corpus,
76
- # for any constant annotated with "may be empty" or "platform-specific default". Add
77
- # the full qualified path (without leading "::") when a genuine risk is found.
78
- RUNTIME_INSPECTION_EXCLUDED = Set[].freeze
79
- private_constant :RUNTIME_INSPECTION_EXCLUDED
95
+ # The `RUBY_*` / `Ruby::*` pairs and `Encoding::UNICODE_VERSION` are defined by the interpreter
96
+ # itself; `File::SEPARATOR` / `Separator` / `PATH_SEPARATOR` / `ALT_SEPARATOR` by its `File`
97
+ # core class (`Separator` is the capital-S alias of `SEPARATOR`, declared in core RBS and just
98
+ # as spellable in project code; `ALT_SEPARATOR` is `nil` off Windows and simply gets no entry
99
+ # there); `Gem::VERSION` by rubygems, which is not the interpreter's to version but IS loaded
100
+ # in every process that can run `rigor` at all. A name that resolves to something other than a
101
+ # non-empty String on the running interpreter is skipped, so listing one costs nothing where it
102
+ # does not exist.
103
+ RUNTIME_STRING_CONSTANTS = %w[
104
+ RUBY_VERSION RUBY_RELEASE_DATE RUBY_PLATFORM RUBY_DESCRIPTION RUBY_COPYRIGHT
105
+ RUBY_ENGINE RUBY_ENGINE_VERSION RUBY_REVISION
106
+ Ruby::VERSION Ruby::RELEASE_DATE Ruby::PLATFORM Ruby::DESCRIPTION Ruby::COPYRIGHT
107
+ Ruby::ENGINE Ruby::ENGINE_VERSION Ruby::REVISION
108
+ File::SEPARATOR File::Separator File::PATH_SEPARATOR File::ALT_SEPARATOR
109
+ Encoding::UNICODE_VERSION
110
+ Gem::VERSION
111
+ ].freeze
112
+ private_constant :RUNTIME_STRING_CONSTANTS
80
113
 
81
114
  NON_EMPTY_STRING = Type::Combinator.non_empty_string.freeze
82
115
  NUMERIC_STRING = Type::Combinator.numeric_string.freeze
83
116
  private_constant :NON_EMPTY_STRING, :NUMERIC_STRING
84
117
 
85
- # --- public API ---------------------------------------------------
86
-
87
- # @param name [String] unqualified constant name (e.g. `"Math::PI"`,
88
- # `"RUBY_VERSION"`, `"Ruby::ENGINE"`)
89
- # @return [Rigor::Type, nil] refined type, or nil to fall through
90
- def self.lookup(name)
91
- FOLDED_CONSTANTS[name] || inspect_runtime_string(name)
92
- end
93
-
94
118
  # --- private ------------------------------------------------------
95
119
 
96
- # Resolves `name` via `const_get` in the analyzer's runtime and returns a refined
97
- # String carrier, or nil.
98
- def self.inspect_runtime_string(name)
99
- return nil if RUNTIME_INSPECTION_EXCLUDED.include?(name)
120
+ # @param value [String] a non-empty string
121
+ # @return [Rigor::Type]
122
+ def self.classify_string(value)
123
+ if Type::Refined.ruby_numeric_literal?(value)
124
+ NUMERIC_STRING
125
+ else
126
+ NON_EMPTY_STRING
127
+ end
128
+ end
129
+ private_class_method :classify_string
100
130
 
131
+ # Reads `name` in the analyzer's runtime, returning its value when that is a non-empty String.
132
+ #
133
+ # Called ONLY with the names in {RUNTIME_STRING_CONSTANTS} — Rigor's own source — and only
134
+ # while this file is being loaded. It is never handed a name from the analysed program.
135
+ #
136
+ # @param name [String] a qualified constant path without a leading "::"
137
+ # @return [String, nil]
138
+ def self.runtime_string_value(name)
101
139
  mod = ::Object
102
140
  name.split("::").each do |part|
103
- # Resolve only constants already present — never let analysing a reference drive
104
- # the analyzer's own runtime to autoload or run a `const_missing` hook. A
105
- # `Digest::UUID` reference in project code otherwise makes `const_get` trigger
106
- # `Digest.const_missing` → `require "digest/uuid"`, and a missing optional library
107
- # raises `LoadError` (a `ScriptError`, not the `NameError` the const_get walk
108
- # expects), which would abort the whole run rather than fall through to the RBS
109
- # tier. `const_defined?(part, false)` answers the same "is this resolvable here"
110
- # question without the side effect — a project-defined constant (the common case)
111
- # is simply absent and returns nil, no exception raised.
112
141
  return nil unless mod.is_a?(::Module) && mod.const_defined?(part, false)
142
+ # `const_defined?` is true for a REGISTERED-BUT-NOT-YET-TRIGGERED autoload, so on its own
143
+ # it does not mean "already in memory" and `const_get` would EXECUTE the target file. Only
144
+ # `Module#autoload?` separates the two: it returns the registered path while the autoload
145
+ # is pending and nil once it has run, so declining on it declines exactly the dangerous
146
+ # case and still resolves a constant genuinely loaded ([#680]).
147
+ return nil if mod.autoload?(part)
113
148
 
114
149
  mod = mod.const_get(part, false)
115
150
  end
116
151
 
117
- return nil unless mod.is_a?(::String) && !mod.empty?
118
-
119
- classify_string(mod)
120
- rescue ::NameError, ::TypeError, ::LoadError
152
+ mod.is_a?(::String) && !mod.empty? ? mod : nil
153
+ rescue ::StandardError, ::ScriptError, ::SystemExit
154
+ # Deliberately wider than the `NameError` / `TypeError` / `LoadError` this used to name.
155
+ # Refining a constant is an optimisation and nothing it can hit justifies stopping the
156
+ # process; `SystemExit` is the one that actually did ([#680]) and is not a `StandardError`,
157
+ # and `ScriptError` covers the `LoadError` family for the same reason. `Interrupt`,
158
+ # `SignalException` and `NoMemoryError` are deliberately NOT caught — a Ctrl-C must keep
159
+ # reaching the user, and an out-of-memory process is not one to keep analysing in.
121
160
  nil
122
161
  end
123
- private_class_method :inspect_runtime_string
162
+ private_class_method :runtime_string_value
163
+
164
+ # @return [Hash{String => Rigor::Type}] frozen, built once at load time.
165
+ def self.build_runtime_string_refinements
166
+ RUNTIME_STRING_CONSTANTS.each_with_object({}) do |name, table|
167
+ value = runtime_string_value(name)
168
+ table[name] = classify_string(value) if value
169
+ end.freeze
170
+ end
171
+ private_class_method :build_runtime_string_refinements
124
172
 
125
- # @param value [String] a non-empty string
126
- # @return [Rigor::Type]
127
- def self.classify_string(value)
128
- if Type::Refined.ruby_numeric_literal?(value)
129
- NUMERIC_STRING
130
- else
131
- NON_EMPTY_STRING
132
- end
173
+ RUNTIME_STRING_REFINEMENTS = build_runtime_string_refinements
174
+ private_constant :RUNTIME_STRING_REFINEMENTS
175
+
176
+ # --- public API ---------------------------------------------------
177
+
178
+ # @param name [String] unqualified constant name (e.g. `"Math::PI"`,
179
+ # `"RUBY_VERSION"`, `"Ruby::ENGINE"`)
180
+ # @return [Rigor::Type, nil] refined type, or nil to fall through
181
+ def self.lookup(name)
182
+ FOLDED_CONSTANTS[name] || RUNTIME_STRING_REFINEMENTS[name]
133
183
  end
134
- private_class_method :classify_string
135
184
  end
136
185
  end
137
186
  end
@@ -66,13 +66,32 @@ module Rigor
66
66
  FILE_NON_EMPTY = ->(_arg_types) { NON_EMPTY_STRING }
67
67
  private_constant :FILE_NON_EMPTY
68
68
 
69
+ STRING_NOMINAL = Type::Combinator.nominal_of("String").freeze
70
+ private_constant :STRING_NOMINAL
71
+
72
+ # `IO.read(name)` / `File.read(name)` return nil ONLY when a `length` argument is given
73
+ # and the stream is at EOF; the no-length form always returns a String (it raises on
74
+ # error). Upstream RBS declares one row with an optional length — `-> String?` for every
75
+ # call shape — so the whole-file idiom `File.read(path, mode: 'rb')` read as nilable and
76
+ # fired `possible-nil-receiver` on working code (redmine's Import#read_file_head, three
77
+ # sites). The refinement drops the nil exactly when no positional length is present; a
78
+ # keyword-only trailing hash (`mode:`, `encoding:`) is not a length. Same family as the
79
+ # staged upstream signature fixes (#159).
80
+ IO_READ_NO_LENGTH = lambda do |arg_types|
81
+ positional = arg_types.grep_v(Type::HashShape)
82
+ positional.size <= 1 ? STRING_NOMINAL : nil
83
+ end
84
+ private_constant :IO_READ_NO_LENGTH
85
+
69
86
  # Frozen ((owner_class_name, method_name, kind) => handler) table. The kind tag is
70
87
  # `:both`, `:singleton`, or `:instance`. New entries SHOULD prefer `:both` unless the
71
88
  # singleton- and instance-side shapes genuinely differ.
72
89
  OVERRIDES = {
73
90
  ["Kernel", :__dir__, :both] => KERNEL_DIR,
74
91
  ["File", :expand_path, :singleton] => FILE_NON_EMPTY,
75
- ["File", :dirname, :singleton] => FILE_NON_EMPTY
92
+ ["File", :dirname, :singleton] => FILE_NON_EMPTY,
93
+ ["IO", :read, :singleton] => IO_READ_NO_LENGTH,
94
+ ["File", :read, :singleton] => IO_READ_NO_LENGTH
76
95
  }.freeze
77
96
  private_constant :OVERRIDES
78
97
 
@@ -38,7 +38,17 @@ module Rigor
38
38
  # whose RBS dangles an interface or type-alias reference gets stubs where an older Rigor discarded the
39
39
  # whole batch and cached an env in which those signatures are inert. Same reasoning as v3: the
40
40
  # marshalled env is the cached value, so it MUST be rebuilt for the fix to take effect.
41
- SCHEMA_VERSION = 6
41
+ # v7: #526 — the def-index seed bundles gain the `:extends` table (`extend M` / `extend self` /
42
+ # bare `module_function`), which `finalize_def_index` folds into the singleton-side method tables. A
43
+ # pre-7 bundle would silently contribute no extends for an unchanged file, so cached bundles must
44
+ # read as misses once and rebuild carrying the slot.
45
+ # v8: #577 / ADR-45 WD1 — a plugin {Plugin::IoBoundary} read that finds its path MISSING now records
46
+ # an absence row ({FileEntry.absent}: `:exists` / `"false"`) into the run-result and plugin-producer
47
+ # dependency descriptors, so a file that appears after a run which probed for it (a `db/schema.rb`
48
+ # added to a schema-less project) invalidates the warm entry. A pre-8 entry carries no absence rows
49
+ # and would validate fresh across exactly that edit, so cached entries must read as misses once and
50
+ # rebuild carrying the rows.
51
+ SCHEMA_VERSION = 8
42
52
 
43
53
  # Per-slot entry value objects. Constructors validate enums / required fields and freeze the resulting
44
54
  # struct so no caller can mutate after the entry is in a Descriptor.
@@ -54,6 +64,11 @@ module Rigor
54
64
  # `:digest`, while the {Runner} run-dependency descriptor and plugin {IoBoundary} reads use `:stat`.
55
65
  VALID_COMPARATORS = %i[digest stat mtime exists].freeze
56
66
 
67
+ # The two `:exists` values — `File.exist?`'s answer rendered with `to_s`, which is what
68
+ # {Descriptor#file_entry_fresh?} compares the live answer against.
69
+ PRESENT = "true"
70
+ ABSENT = "false"
71
+
57
72
  attr_reader :path, :comparator, :value
58
73
 
59
74
  value_fields :path, :comparator, :value
@@ -80,6 +95,30 @@ module Rigor
80
95
  new(path: path, comparator: :stat, value: packed)
81
96
  end
82
97
 
98
+ # ADR-45 WD1 (#577) — the absence row: fresh while `path` does not exist, stale the moment anything
99
+ # (a file, a directory, a symlink) comes into existence there. {Plugin::IoBoundary#read_file}
100
+ # records one when a read fails because the path is missing, so a value computed on "X is absent"
101
+ # invalidates when X appears. Validation is a single `File.exist?` — no stat tuple, no digest,
102
+ # nothing that can move on an unchanged tree — so an absence row never costs a warm run its hit.
103
+ def self.absent(path:)
104
+ new(path: path, comparator: :exists, value: ABSENT)
105
+ end
106
+
107
+ # ADR-45 WD1b (#613) — {.absent}'s positive twin: fresh while `path` exists, stale once it is gone.
108
+ # {Plugin::IoBoundary#file?} / `#directory?` record one when an existence probe found what it asked
109
+ # for and the plugin never read the bytes (a discovery root it globbed, a config file whose mere
110
+ # presence switched a mode). It is the weakest file row — a rewrite in place leaves it fresh — so it
111
+ # is only ever the row for a dependency that really is on existence alone; a read records the `:stat`
112
+ # row over it.
113
+ def self.present(path:)
114
+ new(path: path, comparator: :exists, value: PRESENT)
115
+ end
116
+
117
+ # @return [Boolean] whether this row records the path as absent (see {.absent}).
118
+ def absent?
119
+ comparator == :exists && value == ABSENT
120
+ end
121
+
83
122
  def to_h
84
123
  { "path" => path, "comparator" => comparator.to_s, "value" => value }
85
124
  end
@@ -280,7 +319,8 @@ module Rigor
280
319
  # version) belong in the cache *key*, not the validated dependency set — so a descriptor carrying any
281
320
  # non-file slot is never considered fresh (it was built wrong for this use). ADR-60 WD3 adds `globs`
282
321
  # alongside `files` as a re-validatable slot: a {GlobEntry} is fresh when re-globbing + re-digesting
283
- # reproduces its recorded value.
322
+ # reproduces its recorded value. ADR-45 WD1 (#577): an absence row ({FileEntry.absent}) is fresh
323
+ # while its path is still missing — the negative half of the dependency set.
284
324
  def fresh?
285
325
  return false unless gems.empty? && plugins.empty? && configs.empty? && dependencies.empty?
286
326
 
@@ -54,8 +54,37 @@ module Rigor
54
54
  # loads as nil (a clean cold rebuild — no migration). 12: ADR-103 WD13 / issue #382 adds the effects
55
55
  # sidecar — `effect_collections` (the per-file {Rigor::Effects::FileCollection}s a collecting run
56
56
  # produced) and the `effects_identity` they were produced under; a pre-12 blob mismatches the SCHEMA
57
- # gate and loads as nil (a clean cold rebuild — no migration).
58
- SCHEMA = 12
57
+ # gate and loads as nil (a clean cold rebuild — no migration). 13: issue #644 adds `constant_decls`
58
+ # (the per-file constant PUBLICATION CENSUS — `{qualified name => [literal] | :unpublishable}` — whose
59
+ # diff drives the new `constant:` edge's producer) and grows each seed bundle's row grammar by its own
60
+ # `constant_writes` census; a pre-13 blob mismatches the SCHEMA gate and loads as nil (a clean cold
61
+ # rebuild — no migration). 14: issue #707 widens each seed-bundle def row from `[node_id, name,
62
+ # fingerprint]` to `[node_id, name, fingerprint, nesting]`, carrying issue #681's recorded
63
+ # `Module.nesting` so a bundle-served callee resolves its constants under the declaration that owns it
64
+ # instead of a peel of the receiver's name; a pre-14 blob mismatches the SCHEMA gate and loads as nil (a
65
+ # clean cold rebuild — no migration). The bump is what keeps a pre-14 blob REJECTED rather than MISREAD:
66
+ # a three-element row destructures with `nesting` nil, which is indistinguishable from a top-level def,
67
+ # so every unchanged file would silently keep the pre-fix peel. 15: issue #682 gives each seed bundle a
68
+ # `header_nestings` table — the `Module.nesting` each class / module DECLARATION HEADER is written in —
69
+ # so a bundle-served class resolves its superclass and include names in the cref Ruby resolves them in
70
+ # rather than by peeling its own qualified name; a pre-15 blob mismatches the SCHEMA gate and loads as
71
+ # nil (a clean cold rebuild — no migration). Absence here is gradual rather than misread (the resolver
72
+ # falls back to the peel), but a warm run that peels where a cold run does not is exactly the
73
+ # `--verify-incremental` divergence 14 was bumped for.
74
+ # 16: issue #722 residue 1 gives each seed bundle's `superclasses` values a ROOTED MARKER — a leading
75
+ # `::` on an ancestor name the site wrote rooted (`class X < ::Base`, `Class.new(::Parent)`) — so the
76
+ # resolvers anchor it at the top level instead of walking the enclosing nesting. The value stays a
77
+ # String, so a pre-16 blob deserialises cleanly and is exactly why the gate has to reject it: an
78
+ # un-rooted value is indistinguishable from "not rooted", and every unchanged file would keep the
79
+ # pre-fix answer on a warm run while a cold run gave the right one.
80
+ # 17: issue #716 changes what a seed-bundle def row's `nesting` MEANS without changing its shape. A
81
+ # top-level def now records the EMPTY chain (Ruby's `Module.nesting` there is `[]`), so the resolver
82
+ # anchors its constants at the top level instead of peeling the caller's namespace; `nil` narrows to
83
+ # "no chain recorded at all". A pre-17 blob stored `nil` for exactly the top-level defs that now store
84
+ # `[]`, deserialises cleanly, and would send every unchanged file's top-level helper back to the peel
85
+ # on a warm run while a cold run resolved at the top level — the `--verify-incremental` divergence 14
86
+ # was bumped for, in the same table.
87
+ SCHEMA = 17
59
88
 
60
89
  # The persisted per-file state.
61
90
  # `cache` maps an analyzed file to its diagnostics.
@@ -69,6 +98,12 @@ module Rigor
69
98
  # ADR-46 slice 3:
70
99
  # `missing` maps a consumer to Set<"kind:name"> it looked up and missed.
71
100
  # `class_decls` maps a path to Set<qualified class name> it declares.
101
+ # Issue #644:
102
+ # `constant_decls` maps a path to that file's constant PUBLICATION CENSUS,
103
+ # `{qualified name => [literal] | :unpublishable}`. The descriptor is what the recheck diffs: a name's
104
+ # published answer is a function of the whole project's write set for it, so a value edit, a write
105
+ # becoming unpublishable, or a second declarer appearing / going away all move the answer without
106
+ # moving the name set.
72
107
  # ADR-85 WD2:
73
108
  # `seed_bundles` maps an analyzed path to its per-file discovery contribution (plain-data tables +
74
109
  # `(node_id, name, fingerprint)` def-node handles + content digest), so a warm recheck rebuilds the
@@ -103,7 +138,7 @@ module Rigor
103
138
  # and a vocabulary / catalogue / `effects:` change must invalidate the summaries alone.
104
139
  Payload = Data.define(:cache, :sources, :digests, :analyzed,
105
140
  :symbol_sources, :ancestry_sources, :symbol_fingerprints,
106
- :missing, :class_decls, :seed_bundles, :plugin_fact_digest,
141
+ :missing, :class_decls, :constant_decls, :seed_bundles, :plugin_fact_digest,
107
142
  :return_summaries, :param_table,
108
143
  :effect_collections, :effects_identity)
109
144
 
@@ -218,6 +253,7 @@ module Rigor
218
253
  symbol_fingerprints: data[:symbol_fingerprints] || {},
219
254
  missing: data[:missing] || {},
220
255
  class_decls: data[:class_decls] || {},
256
+ constant_decls: data[:constant_decls] || {},
221
257
  seed_bundles: data[:seed_bundles] || {},
222
258
  plugin_fact_digest: data[:plugin_fact_digest],
223
259
  return_summaries: data[:return_summaries] || {},
@@ -241,6 +277,7 @@ module Rigor
241
277
  symbol_fingerprints: payload.symbol_fingerprints,
242
278
  missing: payload.missing,
243
279
  class_decls: payload.class_decls,
280
+ constant_decls: payload.constant_decls,
244
281
  seed_bundles: payload.seed_bundles,
245
282
  plugin_fact_digest: payload.plugin_fact_digest,
246
283
  return_summaries: payload.return_summaries,
@@ -9,11 +9,28 @@ require "rbs"
9
9
  #
10
10
  # Patch policy (purely additive):
11
11
  #
12
- # - `_dump` returns an empty string. The cached env loses per-node source-position info, but Rigor does not
13
- # consult `RBS::Location` from any analysis code path (every diagnostic uses Prism's own location), so the
14
- # loss is inert in practice.
15
- # - `_load` reconstructs a sentinel Location backed by an empty `<cached>` Buffer. Code paths that DID consult
16
- # Location after a cache hit see a benign zero-range value rather than crashing.
12
+ # - `_dump` returns the buffer's NAME and nothing else. Per-node source POSITIONS are still dropped (Rigor
13
+ # does not consult them from any analysis path every diagnostic uses Prism's own location), but the
14
+ # file a declaration came from is cheap to keep and is not inert: `rbs.coverage.definition-build-failed`
15
+ # names the conflicting signature files, and dropping the name made a warm run omit that clause while a
16
+ # cold run printed it, so the same project said different things by cache state (issue #696 review,
17
+ # second pass). It costs cache SIZE: a `_dump` payload is raw bytes, not a linkable object graph, so the
18
+ # path is written once per Location rather than once per buffer. Memoising the coerced name per buffer was
19
+ # tried and changes nothing for exactly that reason. Measured +6.6% on a realistic project (Rigor's own
20
+ # 349-file `lib`: 4,332K to 4,616K) and +12.3% on a one-file project (1,672K to 1,876K), both stable
21
+ # across reps — the env blob is fixed overhead, so the ratio falls as a project's own cached data grows
22
+ # and the realistic figure is the lower one. That is the price of a diagnostic that reads the same warm
23
+ # and cold; positions, which are far more numerous and which nothing reads, stay dropped.
24
+ # - `_load` reconstructs a zero-range Location over that name, falling back to a `<cached>` sentinel when
25
+ # the dump carried none. Code paths that DID consult Location after a cache hit see a benign value rather
26
+ # than crashing, and one that reads `buffer.name` now sees the real path.
27
+ # - Both directions were exercised rather than assumed. A NEW blob read by an OLDER `_load` — which ignored
28
+ # its argument — loads and behaves exactly as before; that direction is clean. An OLD blob read by THIS
29
+ # `_load` also loads without raising, and the sentinel fallback keeps it from ever naming `<cached>` as a
30
+ # path — but the conflicting-files clause then goes missing, so a warm run off a pre-change blob says less
31
+ # than a cold run does. ADR-6's store never evicts, so that would persist. `Cache::Store::FORMAT_VERSION`
32
+ # is therefore bumped to 3: `PAYLOAD_ABI_VERSION` already rebuilds across a release, and the bump closes
33
+ # the same-version window too.
17
34
  #
18
35
  # Idempotent: the guard checks `method_defined?(:_dump)` so requiring this file twice (or against an upstream
19
36
  # rbs that adds Marshal hooks itself) is a no-op.
@@ -33,13 +50,19 @@ require "rbs"
33
50
  # reference. Both classes are value objects fully described by `to_s`, so the round-trip is lossless.
34
51
  module RBS
35
52
  class Location
53
+ # The name a Location gets back when the dump carried none — an old blob, or a buffer that never had
54
+ # one. Never a real path, so a consumer that reports file names must filter it
55
+ # ({Rigor::Environment::RbsLoader::CACHED_LOCATION_BUFFER_NAME}).
56
+ CACHED_BUFFER_NAME = "<cached>"
57
+
36
58
  unless method_defined?(:_dump)
37
59
  def _dump(_)
38
- ""
60
+ buffer&.name.to_s
39
61
  end
40
62
 
41
- def self._load(_)
42
- new(buffer: ::RBS::Buffer.new(name: "<cached>", content: ""), start_pos: 0, end_pos: 0)
63
+ def self._load(name)
64
+ name = CACHED_BUFFER_NAME if name.nil? || name.empty?
65
+ new(buffer: ::RBS::Buffer.new(name: name, content: ""), start_pos: 0, end_pos: 0)
43
66
  end
44
67
  end
45
68
  end
@@ -29,7 +29,16 @@ module Rigor
29
29
  # fail the header check and read as silent misses; the `schema_version.txt` marker additionally carries
30
30
  # this version, so the first writable run after a bump clears the root and reclaims the unreadable
31
31
  # bytes.
32
- FORMAT_VERSION = 2
32
+ #
33
+ # v3 (issue #696 review, second pass): `RBS::Location#_dump` now carries the buffer NAME rather than an
34
+ # empty string, so an env blob written before this change reconstructs every location behind the
35
+ # `<cached>` sentinel. That degrades gracefully — the sentinel is filtered, never printed as a path —
36
+ # but `rbs.coverage.definition-build-failed` then omits its conflicting-files clause, so a warm run off
37
+ # a pre-change blob says less than a cold run does, indefinitely: ADR-6's store never evicts.
38
+ # `PAYLOAD_ABI_VERSION` already rebuilds across a RELEASE, so the exposure is a same-version tree; this
39
+ # closes that window too, because "the same project reports differently depending on how you ran it" is
40
+ # the defect the diagnostic exists to end and a stale blob reintroduces it.
41
+ FORMAT_VERSION = 3
33
42
 
34
43
  # Payload ABI version. Store values are mostly Marshal blobs of Rigor/RBS objects, so a Rigor release
35
44
  # upgrade is an ABI boundary even when the byte layout and descriptor schema are unchanged. Folding the
@@ -217,24 +217,23 @@ module Rigor
217
217
  # class. Tier 1 seeds unconditionally because it only reclassifies sites it already counted; Tier 2 adds
218
218
  # sites to a denominator `--threshold` gates CI on, which is why only the latter is gated on a feature id.
219
219
  def scope_with_inferred_params(paths, configuration, environment, workers)
220
- base = Scope.empty(environment: environment)
221
- seed = {}
222
-
223
- discovered = Inference::ScopeIndexer.discovered_classes_for_paths(paths)
224
- seed[:discovered_classes] = discovered unless discovered.empty?
225
-
226
- table = Inference::ParameterInferenceCollector.collect(
227
- files: paths, environment: environment, target_ruby: configuration.target_ruby, workers: workers
220
+ CoverageScan.discovery_seeded_scope(
221
+ files: paths, configuration: configuration, environment: environment,
222
+ parameter_inference: true, workers: workers
228
223
  )
229
- seed[:param_inferred_types] = table unless table.empty?
230
-
231
- return base if seed.empty?
232
-
233
- base.with_discovery(base.discovery.with(**seed))
234
224
  end
235
225
 
226
+ # Issue #686 — an unmeasured file fails the build the way a parse error already does, and for the same
227
+ # reason: the command could not measure part of what it was asked about, so its ratio is an answer to
228
+ # a smaller question than the user asked. Deliberately NOT routed through `--threshold`, which the
229
+ # line below applies to a ratio: a crashed file's mutants leave `killed + survived` at zero, the
230
+ # project ratio is then computed over the files that DID work, and a run that measured nothing at all
231
+ # came out at 1.0 and passed every threshold. It is also deliberately not the `harness_errors` count,
232
+ # which #264 keeps out of the exit code on purpose — a few rescued mutants are the transient that
233
+ # bucket exists to make visible, while a wholly unmeasured FILE is not a transient.
236
234
  def determine_protection_exit(report, options)
237
235
  return 1 unless report.parse_errors.empty?
236
+ return 1 if unmeasured_files_in(report).positive?
238
237
 
239
238
  threshold = options[:threshold]
240
239
  return 0 if threshold.nil?
@@ -242,6 +241,12 @@ module Rigor
242
241
  report.ratio < threshold ? 1 : 0
243
242
  end
244
243
 
244
+ # Issue #686 — the one reader of the count, shared with the stderr warning. The Tier-1 protection
245
+ # report has no such notion, so a report without the field reads as zero rather than erroring.
246
+ def unmeasured_files_in(report)
247
+ report.respond_to?(:unmeasured_files) ? report.unmeasured_files : 0
248
+ end
249
+
245
250
  def usage_error
246
251
  @err.puts("coverage: at least one path is required")
247
252
  @err.puts(USAGE)
@@ -254,11 +259,11 @@ module Rigor
254
259
 
255
260
  # The protection scan must see the same receiver types `rigor check` does — including plugin-contributed
256
261
  # `dynamic_return` types (a controller's `params` → `ActionController::Parameters`, a `Model.where` →
257
- # `ActiveRecord::Relation[Model]`). The bare `CoverageScan.project_environment` carries only the RBS environment
258
- # (no plugin registry), so every plugin-typed receiver reads `Dynamic` and its dispatch site is miscounted as
259
- # *unprotected* — a systematic undercount of what Rigor actually types on a plugin-using project.
260
- # `ProjectContext` builds the plugin-aware environment (registry materialised + the per-run prepare pass that
261
- # primes producers like the controller / model index) exactly as the LSP and the runner do.
262
+ # `ActiveRecord::Relation[Model]`). A bare RBS environment carries no plugin registry, so every plugin-typed
263
+ # receiver reads `Dynamic` and its dispatch site is miscounted as *unprotected* — a systematic undercount of
264
+ # what Rigor actually types on a plugin-using project. `ProjectContext` builds the plugin-aware environment
265
+ # (registry materialised + the per-run prepare pass that primes producers like the controller / model index)
266
+ # exactly as the LSP, the runner, and since #513 the precision path all do.
262
267
  def plugin_aware_environment(configuration)
263
268
  LanguageServer::ProjectContext.new(configuration: configuration).environment
264
269
  end
@@ -9,6 +9,8 @@ require_relative "../protection/dependency_closure"
9
9
  require_relative "../protection/discovery_seed"
10
10
  require_relative "../protection/mutation_cache"
11
11
 
12
+ require_relative "measurement_integrity_warning"
13
+
12
14
  module Rigor
13
15
  class CLI
14
16
  # ADR-63 Tier 2 + ADR-70 — the mutation-effectiveness and fused static∪dynamic protection paths, factored out of
@@ -43,17 +45,13 @@ module Rigor
43
45
 
44
46
  private
45
47
 
46
- # @param report [MutationProtectionReport, FusedProtectionReport] both expose `total_harness_errors`.
48
+ # Both measurement-integrity warnings, and the escalation rule between them, live in
49
+ # {MeasurementIntegrityWarning}. Only the floor stays here: it is this command's policy, and
50
+ # `coverage_command_spec` reads it off this module.
51
+ #
52
+ # @param report [MutationProtectionReport, FusedProtectionReport]
47
53
  def warn_harness_errors(report)
48
- count = report.total_harness_errors
49
- return if count < HARNESS_ERROR_WARN_FLOOR
50
-
51
- @err.puts(
52
- "coverage: #{count} mutants failed inside the measurement harness (\"harness_errors\", " \
53
- "at/above the #{HARNESS_ERROR_WARN_FLOOR}-mutant floor) — excluded from the ratio like a " \
54
- "parse-invalid mutant, but this many suggests a harness defect rather than one-off noise. " \
55
- "Investigate before trusting --threshold on this run."
56
- )
54
+ MeasurementIntegrityWarning.emit(report, err: @err, floor: HARNESS_ERROR_WARN_FLOOR)
57
55
  end
58
56
 
59
57
  # The cross-file knowledge Tier 2 measures with — the #253 gate, and the ONLY place in this feature that
@@ -262,13 +260,11 @@ module Rigor
262
260
  # the two sets were assembled.
263
261
  #
264
262
  # The two cache phases each get their OWN {Cache::FileDigest.with_run} scope and the MEASUREMENT sits
265
- # between them, deliberately: that scope installs a per-path digest memo, and {Protection::ClosureKillOracle}
266
- # rewrites one process-private temp file per mutant and digests it through a buffer binding. A memo
267
- # spanning the measurement would hand every mutant after the first the FIRST one's digest, the mutated
268
- # file's discovery bundle would never be re-walked, and the run would report zero cross-file kills a
269
- # plausible-looking number rather than an error. The oracle's own comment states the same invariant from
270
- # the other side. The cost of two scopes is one extra SHA-256 per cached file, against a measurement
271
- # that is hundreds of analyses.
263
+ # between them, deliberately: that scope installs a per-path digest memo. {Protection::ClosureKillOracle}
264
+ # now writes each mutant to a fresh block-scoped path, so a memo spanning the measurement could no
265
+ # longer serve one mutant another's digest but keeping the measurement outside any memo scope stays
266
+ # correct by construction rather than by the oracle's path discipline, and costs only one extra
267
+ # SHA-256 per cached file against a measurement that is hundreds of analyses.
272
268
  def measure_mutation_files(paths, cache:, scanner:, context:, configuration:, workers:)
273
269
  cached = with_digest_run(configuration) { paths.to_h { |path| [path, cache.fetch(path)] }.compact }
274
270
  pending = paths - cached.keys