rigortype 0.4.0 → 0.4.2

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 (237) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/data/builtins/ruby_core/io.yml +1 -0
  4. data/data/builtins/ruby_core/proc.yml +4 -0
  5. data/data/builtins/ruby_core/random.yml +4 -0
  6. data/data/builtins/ruby_core/struct.yml +3 -0
  7. data/data/core_overlay/env.rbs +34 -0
  8. data/data/core_overlay/gc.rbs +50 -0
  9. data/data/core_overlay/hash.rbs +45 -0
  10. data/data/core_overlay/integer.rbs +31 -0
  11. data/data/core_overlay/io_buffer.rbs +27 -0
  12. data/data/core_overlay/kernel.rbs +40 -0
  13. data/data/core_overlay/match_data.rbs +28 -0
  14. data/data/core_overlay/module.rbs +51 -0
  15. data/data/core_overlay/object_space.rbs +29 -0
  16. data/data/core_overlay/proc.rbs +45 -0
  17. data/data/core_overlay/range.rbs +40 -0
  18. data/data/core_overlay/source_range.rbs +78 -0
  19. data/data/core_overlay/string.rbs +86 -0
  20. data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
  21. data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
  22. data/docs/handbook/01-getting-started.md +3 -3
  23. data/docs/handbook/06-classes.md +3 -1
  24. data/docs/handbook/08-understanding-errors.md +3 -2
  25. data/docs/handbook/11-sig-gen.md +6 -0
  26. data/docs/handbook/appendix-liskov.md +5 -0
  27. data/docs/install.md +87 -15
  28. data/docs/manual/02-cli-reference.md +2 -2
  29. data/docs/manual/03-configuration.md +6 -1
  30. data/docs/manual/04-diagnostics.md +2 -1
  31. data/docs/manual/06-baseline.md +2 -0
  32. data/docs/manual/07-plugins.md +1 -1
  33. data/docs/manual/08-skills.md +4 -3
  34. data/docs/manual/12-caching.md +43 -4
  35. data/docs/manual/14-rails-quickstart.md +19 -11
  36. data/docs/manual/18-removing-dead-code.md +32 -2
  37. data/docs/manual/plugins/README.md +6 -1
  38. data/docs/manual/plugins/rigor-activerecord.md +21 -0
  39. data/docs/manual/plugins/rigor-alba.md +60 -0
  40. data/docs/manual/plugins/rigor-minitest.md +22 -4
  41. data/docs/manual/plugins/rigor-typelizer.md +71 -0
  42. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
  43. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
  44. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
  45. data/lib/rigor/analysis/check_rules/rule_ids.rb +4 -0
  46. data/lib/rigor/analysis/check_rules/source_arity.rb +184 -61
  47. data/lib/rigor/analysis/check_rules.rb +407 -144
  48. data/lib/rigor/analysis/dependency_recorder.rb +215 -18
  49. data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
  50. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
  51. data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
  52. data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
  53. data/lib/rigor/analysis/erb_template_detector.rb +2 -1
  54. data/lib/rigor/analysis/incremental.rb +29 -9
  55. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +291 -0
  56. data/lib/rigor/analysis/incremental_run_slot.rb +173 -0
  57. data/lib/rigor/analysis/incremental_session.rb +287 -50
  58. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  59. data/lib/rigor/analysis/reachability/graph.rb +123 -23
  60. data/lib/rigor/analysis/reachability/scan.rb +290 -36
  61. data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
  62. data/lib/rigor/analysis/rule_catalog.rb +43 -0
  63. data/lib/rigor/analysis/run_cache_key.rb +43 -9
  64. data/lib/rigor/analysis/run_stats.rb +2 -2
  65. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +88 -6
  66. data/lib/rigor/analysis/runner/pool_coordinator.rb +52 -13
  67. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
  68. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  69. data/lib/rigor/analysis/runner.rb +371 -49
  70. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  71. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  72. data/lib/rigor/analysis/template_unit_paths.rb +6 -4
  73. data/lib/rigor/analysis/template_units.rb +15 -5
  74. data/lib/rigor/analysis/worker_session.rb +11 -7
  75. data/lib/rigor/cache/descriptor.rb +40 -1
  76. data/lib/rigor/cache/file_digest.rb +28 -0
  77. data/lib/rigor/cache/incremental_snapshot.rb +92 -8
  78. data/lib/rigor/cache/store.rb +63 -3
  79. data/lib/rigor/cache/value_digest.rb +325 -0
  80. data/lib/rigor/cli/annotate_command.rb +5 -0
  81. data/lib/rigor/cli/baseline_command.rb +7 -18
  82. data/lib/rigor/cli/check_command.rb +85 -14
  83. data/lib/rigor/cli/doctor_command.rb +1 -1
  84. data/lib/rigor/cli/effects_report.rb +2 -2
  85. data/lib/rigor/cli/trace_command.rb +5 -0
  86. data/lib/rigor/cli/type_of_command.rb +5 -3
  87. data/lib/rigor/cli/unused_command.rb +16 -10
  88. data/lib/rigor/cli.rb +11 -3
  89. data/lib/rigor/config_audit.rb +3 -2
  90. data/lib/rigor/config_path.rb +29 -0
  91. data/lib/rigor/configuration/severity_profile.rb +11 -0
  92. data/lib/rigor/configuration.rb +52 -7
  93. data/lib/rigor/effects/plugin_facts.rb +6 -6
  94. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  95. data/lib/rigor/environment/gated_signature_guard.rb +43 -0
  96. data/lib/rigor/environment/gated_signature_plan.rb +339 -0
  97. data/lib/rigor/environment/lockfile_resolver.rb +10 -1
  98. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  99. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  100. data/lib/rigor/environment/rbs_loader.rb +284 -15
  101. data/lib/rigor/environment/required_features.rb +206 -0
  102. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  103. data/lib/rigor/environment.rb +34 -73
  104. data/lib/rigor/inference/acceptance.rb +28 -3
  105. data/lib/rigor/inference/block_parameter_binder.rb +29 -13
  106. data/lib/rigor/inference/builtins/cruby_definers.rb +61 -0
  107. data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
  108. data/lib/rigor/inference/captured_locals.rb +7 -1
  109. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  110. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  111. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  112. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  113. data/lib/rigor/inference/declaration_walk.rb +7 -0
  114. data/lib/rigor/inference/def_node_resolver.rb +26 -3
  115. data/lib/rigor/inference/definer_resolution.rb +266 -0
  116. data/lib/rigor/inference/expression_typer.rb +351 -61
  117. data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
  118. data/lib/rigor/inference/global_write_census.rb +7 -4
  119. data/lib/rigor/inference/guard_rebinding.rb +54 -13
  120. data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
  121. data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
  122. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +282 -0
  123. data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
  124. data/lib/rigor/inference/in_effect_refinements.rb +256 -0
  125. data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
  126. data/lib/rigor/inference/key_presence_guard.rb +303 -0
  127. data/lib/rigor/inference/macro_block_self_type.rb +73 -14
  128. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  129. data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
  130. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
  131. data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
  132. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
  133. data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
  134. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
  135. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
  136. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +305 -54
  137. data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
  138. data/lib/rigor/inference/method_dispatcher.rb +44 -11
  139. data/lib/rigor/inference/module_function_state.rb +214 -0
  140. data/lib/rigor/inference/multi_target_binder.rb +21 -16
  141. data/lib/rigor/inference/narrowing.rb +166 -22
  142. data/lib/rigor/inference/object_mixins.rb +180 -0
  143. data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
  144. data/lib/rigor/inference/project_patched_methods.rb +10 -5
  145. data/lib/rigor/inference/project_patched_scanner.rb +71 -3
  146. data/lib/rigor/inference/refine_census.rb +239 -0
  147. data/lib/rigor/inference/refine_self.rb +317 -0
  148. data/lib/rigor/inference/refined_dispatch/own_method.rb +403 -0
  149. data/lib/rigor/inference/refined_dispatch.rb +272 -0
  150. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  151. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  152. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  153. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  154. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  155. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +143 -0
  156. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  157. data/lib/rigor/inference/scope_indexer.rb +2129 -354
  158. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  159. data/lib/rigor/inference/statement_evaluator.rb +149 -32
  160. data/lib/rigor/inference/string_mutation.rb +9 -3
  161. data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
  162. data/lib/rigor/inference/version_guard.rb +19 -10
  163. data/lib/rigor/language_server/project_context.rb +31 -0
  164. data/lib/rigor/language_server/server.rb +18 -0
  165. data/lib/rigor/plugin/base.rb +28 -8
  166. data/lib/rigor/plugin/inflector.rb +1 -1
  167. data/lib/rigor/plugin/io_boundary.rb +124 -7
  168. data/lib/rigor/plugin/isolation.rb +1 -1
  169. data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
  170. data/lib/rigor/plugin/trust_policy.rb +3 -3
  171. data/lib/rigor/project_environment.rb +29 -6
  172. data/lib/rigor/protection/discovery_seed.rb +18 -9
  173. data/lib/rigor/protection/mutation_cache.rb +4 -4
  174. data/lib/rigor/protection/mutator.rb +1 -1
  175. data/lib/rigor/reflection/constant_ancestors.rb +211 -46
  176. data/lib/rigor/reflection/constant_path.rb +33 -7
  177. data/lib/rigor/reflection.rb +41 -10
  178. data/lib/rigor/scope/discovery_index.rb +258 -3
  179. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  180. data/lib/rigor/scope/resolution_chain.rb +1032 -0
  181. data/lib/rigor/scope.rb +480 -328
  182. data/lib/rigor/sig_gen/alias_index.rb +1 -1
  183. data/lib/rigor/sig_gen/classification.rb +4 -1
  184. data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
  185. data/lib/rigor/sig_gen/generator.rb +77 -21
  186. data/lib/rigor/sig_gen/method_candidate.rb +2 -2
  187. data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
  188. data/lib/rigor/source/alias_names.rb +52 -0
  189. data/lib/rigor/source/literals.rb +3 -2
  190. data/lib/rigor/source/symbol_name.rb +20 -0
  191. data/lib/rigor/type/combinator.rb +59 -3
  192. data/lib/rigor/type/float_range.rb +4 -1
  193. data/lib/rigor/type/nominal.rb +6 -1
  194. data/lib/rigor/version.rb +1 -1
  195. data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
  196. data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
  197. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
  198. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
  199. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
  200. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
  201. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
  202. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
  203. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
  204. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
  205. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
  206. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
  207. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
  208. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
  209. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
  210. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
  211. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
  212. data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
  213. data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
  214. data/plugins/rigor-alba/sig/alba.rbs +4 -0
  215. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
  216. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
  217. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +13 -2
  218. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
  219. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
  220. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
  221. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
  222. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
  223. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
  224. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
  225. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
  226. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
  227. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
  228. data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
  229. data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
  230. data/sig/rigor/environment.rbs +2 -0
  231. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  232. data/sig/rigor/scope.rbs +69 -4
  233. data/sig/rigor/source.rbs +4 -0
  234. data/sig/rigor.rbs +3 -1
  235. data/skills/rigor-project-init/SKILL.md +37 -11
  236. data/skills/rigor-project-init/references/02-configure.md +7 -2
  237. metadata +90 -1
@@ -882,7 +882,7 @@ module Rigor
882
882
 
883
883
  before, separator, = file.rpartition("/lib/")
884
884
  root = separator.empty? ? File.dirname(file) : before
885
- relative.map { |rel| File.expand_path(rel, root) }
885
+ relative.map { |rel| File.absolute_path(rel, root) }
886
886
  end
887
887
 
888
888
  # ADR-28 — the path-scoped method-protocol contracts this plugin contributes. Defaults to the
@@ -940,6 +940,17 @@ module Rigor
940
940
  # changed. The producer id is auto-prefixed `plugin.<manifest.id>.` per ADR-7 § "Slice 6-C" so plugin
941
941
  # caches stay sandboxed from built-in producers.
942
942
  #
943
+ # #1558 — whichever way the value arrives, the boundary ends up holding every row of the producer's
944
+ # dependency descriptor. A hit ran no block and read nothing, so the served entry's stored rows are
945
+ # replayed into it ({IoBoundary#replay}); a miss read its inputs itself, and replaying the descriptor it
946
+ # just recorded adds the evaluated `watch:` rows, the only rows the block did not record. The boundary
947
+ # is per plugin and accumulates for the run, so the rows reach the ADR-45 run-result descriptor
948
+ # (`Runner#build_run_dependency_descriptor`) and the descriptor of every producer of this plugin
949
+ # computed after it, whether its block asked this one or it composes a value this one returned
950
+ # earlier. Without the replay a producer recomputed after a served one stored no row for the served
951
+ # value's inputs, and neither did the run: rigor-activerecord's `:model_index`, recomputed after a
952
+ # model edit while `:schema_table` hit, kept its stale columns after `db/schema.rb` changed.
953
+ #
943
954
  # When `services.cache_store` is `nil` (e.g. CLI `--no-cache`), the callable bypasses the cache and
944
955
  # runs the producer block every time — same semantics as the v0.0.9 cache surface for built-in
945
956
  # producers.
@@ -974,11 +985,9 @@ module Rigor
974
985
  generation_cap: producer[:generation_cap],
975
986
  params: params,
976
987
  serialize: pair_serializer(producer[:serialize]),
977
- deserialize: pair_deserializer(producer[:deserialize])
978
- ) do
979
- value = compute.call
980
- [value, producer_dependency_descriptor(producer)]
981
- end
988
+ deserialize: pair_deserializer(producer[:deserialize]),
989
+ on_hit: ->(dependencies) { io_boundary.replay(dependencies) }
990
+ ) { computed_producer_pair(producer, compute) }
982
991
  end
983
992
  end
984
993
 
@@ -1151,7 +1160,7 @@ module Rigor
1151
1160
 
1152
1161
  def collect_glob_files(roots, patterns)
1153
1162
  matched = roots.flat_map do |root|
1154
- absolute = File.expand_path(root.to_s)
1163
+ absolute = File.absolute_path(root.to_s)
1155
1164
  next [] unless File.directory?(absolute)
1156
1165
 
1157
1166
  patterns.flat_map { |pattern| Dir.glob(File.join(absolute, pattern.to_s)) }
@@ -1196,6 +1205,17 @@ module Rigor
1196
1205
  Cache::Descriptor.compose(auto_built, extra)
1197
1206
  end
1198
1207
 
1208
+ # #1558 — the miss half of {#cache_for}. The block's own reads are in the boundary already, so replaying
1209
+ # the descriptor it just recorded adds the evaluated `watch:` rows, as a hit's replay of the stored
1210
+ # descriptor does; a producer computing around this one then records one row set whichever way this
1211
+ # value arrived.
1212
+ def computed_producer_pair(producer, compute)
1213
+ value = compute.call
1214
+ dependencies = producer_dependency_descriptor(producer)
1215
+ io_boundary.replay(dependencies)
1216
+ [value, dependencies]
1217
+ end
1218
+
1199
1219
  # ADR-60 WD3 — the dependency descriptor stored beside the producer's value, built AFTER the block ran
1200
1220
  # so every in-block `io_boundary` read is captured, plus the evaluated `watch:` glob rows.
1201
1221
  #
@@ -1224,7 +1244,7 @@ module Rigor
1224
1244
  Array(tuples).flat_map do |tuple|
1225
1245
  roots, *patterns = Array(tuple)
1226
1246
  Array(roots).flat_map do |root|
1227
- absolute = File.expand_path(root.to_s)
1247
+ absolute = File.absolute_path(root.to_s)
1228
1248
  patterns.map { |pattern| Cache::Descriptor::GlobEntry.compute(root: absolute, pattern: pattern.to_s) }
1229
1249
  end
1230
1250
  end.uniq
@@ -54,7 +54,7 @@ module Rigor
54
54
  # by `rigor plugins` (ADR-90) to probe {available?} at activation time — so a standalone install
55
55
  # where inflection silently degrades reports the degradation instead of an unqualified `[OK]`. When
56
56
  # a new plugin adopts the helper, add its manifest id here.
57
- CONSUMER_PLUGIN_IDS = %w[actionmailer actionpack activerecord factorybot rails-routes].freeze
57
+ CONSUMER_PLUGIN_IDS = %w[actionmailer actionpack activerecord alba factorybot rails-routes].freeze
58
58
 
59
59
  # Raised when `ActiveSupport::Inflector` is required for an inflection but cannot be loaded. Caught by
60
60
  # the per-plugin isolation boundary, so it surfaces as "this plugin produced no diagnostics" rather than
@@ -43,10 +43,39 @@ module Rigor
43
43
  # - `#cache_descriptor` — flushes the accumulated entries into a fresh {Cache::Descriptor} for the
44
44
  # contribution that built it. URL fetches contribute `ConfigEntry` rows keyed `"url:#{url}"` with the
45
45
  # response body's SHA-256 so contributions invalidate when the remote document changes.
46
+ # - `#replay(descriptor)` — records a {Cache::Descriptor}'s rows as though this boundary had observed
47
+ # them (#1558). A producer served from its own cache ran no block and read nothing, so
48
+ # {Plugin::Base#cache_for} replays the served entry's stored dependency rows here; the run-result
49
+ # descriptor and any enclosing producer's descriptor then carry the served value's inputs.
46
50
  class IoBoundary
47
51
  URL_TIMEOUT_SECONDS = 10
48
52
  URL_MAX_BYTES = 10 * 1024 * 1024
49
53
 
54
+ ATTRIBUTION_KEY = :rigor_io_boundary_attribution
55
+ private_constant :ATTRIBUTION_KEY
56
+
57
+ # ADR-45 WD2 (#1507) — runs the block with `sink` (an Array) receiving every file and listing row ANY
58
+ # boundary records on this thread meanwhile, in addition to the boundary's own table, and returns the
59
+ # block's value. `Analysis::Runner` wraps each file's analysis in one when it records dependencies, so the
60
+ # `--incremental` session can keep the reads one file's analysis made beside that file's cached answer: a
61
+ # later run that serves the answer without re-analysing the file still has to validate what it read.
62
+ #
63
+ # A thread variable rather than a fiber-local, so a read a plugin makes from inside an `Enumerator` still
64
+ # lands in the sink. The boundary's own table is unchanged by it, including its first-existence-row-stands
65
+ # rule: the sink records what each read observed. Only reads are credited: a value a plugin memoised reaches
66
+ # later files without one, so the first file to trigger the read is the only one credited with it.
67
+ def self.attributing(sink)
68
+ previous = Thread.current.thread_variable_get(ATTRIBUTION_KEY)
69
+ Thread.current.thread_variable_set(ATTRIBUTION_KEY, sink)
70
+ yield
71
+ ensure
72
+ Thread.current.thread_variable_set(ATTRIBUTION_KEY, previous)
73
+ end
74
+
75
+ def self.attribute(entry)
76
+ Thread.current.thread_variable_get(ATTRIBUTION_KEY)&.push(entry)
77
+ end
78
+
50
79
  attr_reader :policy, :plugin_id
51
80
 
52
81
  def initialize(policy:, plugin_id:, http_client: DefaultHttpClient.new)
@@ -61,7 +90,7 @@ module Rigor
61
90
  @first_refused_path = nil
62
91
  end
63
92
 
64
- # Issue #959 — `TrustPolicy#allow_read?` refuses a path purely by `File.expand_path` comparison
93
+ # Issue #959 — `TrustPolicy#allow_read?` refuses a path purely by `File.absolute_path` comparison
65
94
  # (ADR-2's documented bound; no `File.realpath`), so a project rooted under a symlink (macOS'
66
95
  # `/tmp` → `/private/tmp` alias is the common case) can have every plugin read refused with no
67
96
  # visible cause: the boundary answered "not readable" and the plugin swallowed or surfaced only its
@@ -96,7 +125,7 @@ module Rigor
96
125
  # outcome is recorded: a path that exists but cannot be read (`EISDIR`, `EACCES`) is a failure the
97
126
  # plugin reports, not a probe of absence, and records nothing — as before.
98
127
  def read_file(path)
99
- absolute = File.expand_path(path.to_s)
128
+ absolute = File.absolute_path(path.to_s)
100
129
  unless @policy.allow_read?(absolute)
101
130
  record_refusal(absolute)
102
131
  raise AccessDeniedError.new(
@@ -159,7 +188,7 @@ module Rigor
159
188
  # @param path — project directory; relative paths expand against the working directory
160
189
  # @return the absolute paths directly under `path` in `Dir.glob` order; empty when not a directory
161
190
  def list_directory(path)
162
- absolute = File.expand_path(path.to_s)
191
+ absolute = File.absolute_path(path.to_s)
163
192
  entries = Dir.glob(File.join(absolute, "*"))
164
193
  unless @policy.allow_read?(absolute)
165
194
  record_refusal(absolute)
@@ -170,6 +199,44 @@ module Rigor
170
199
  entries
171
200
  end
172
201
 
202
+ # Issue #1652 — the MEMBERSHIP fingerprint of a `Dir.glob` pattern under `root`, `**` included. Returns the
203
+ # matching paths and records one `:names` {Cache::Descriptor::GlobEntry} over `root` and `pattern`: the row
204
+ # reads stale when a match appears or disappears, in a subdirectory created after the read too, and NOT
205
+ # when a match's bytes or stat tuple move. A caller whose answer depends on a match's content MUST
206
+ # {#read_file} each match it reads; that row carries the edit. A stat signature here would read stale on
207
+ # every `touch`, checkout or restored CI cache, though the content the caller read is unchanged (#979's
208
+ # reasoning). {#list_directory} keeps its `:stat` listing row.
209
+ #
210
+ # The pattern must stay under `root`, since the policy is checked against `root` alone: a `..` segment, a
211
+ # brace (`{..,x}`) or a backslash escape (`.\\./`) raises {AccessDeniedError} (`:read_outside_scope`).
212
+ # The last two are refused outright because `Dir.glob` can spell `..` with them. Out of policy, `root` is
213
+ # globbed truthfully and contributes no row, as in {#list_directory}.
214
+ #
215
+ # @param root — project directory; relative paths expand against the working directory
216
+ # @param pattern — a `Dir.glob` pattern relative to `root`, such as `"ext/**/extconf.rb"`
217
+ # @return the absolute matching paths in `Dir.glob` order
218
+ def glob(root, pattern)
219
+ absolute = File.absolute_path(root.to_s)
220
+ pattern = pattern.to_s
221
+ if pattern.match?(/[{\\]/) || pattern.split("/").include?("..")
222
+ raise AccessDeniedError.new(
223
+ "plugin #{@plugin_id.inspect} cannot glob #{pattern.inspect} under #{absolute.inspect}: " \
224
+ "a `..` segment, a brace or a backslash can leave the root the trusted-read scope was checked against",
225
+ reason: :read_outside_scope,
226
+ resource: File.join(absolute, pattern)
227
+ )
228
+ end
229
+
230
+ entries = Dir.glob(File.join(absolute, pattern))
231
+ unless @policy.allow_read?(absolute)
232
+ record_refusal(absolute)
233
+ return entries
234
+ end
235
+
236
+ record_glob_entry(absolute, pattern, mode: :names)
237
+ entries
238
+ end
239
+
173
240
  # Fetches the URL when the policy permits it. Returns the response body. Raises {AccessDeniedError} when
174
241
  # the policy is `:disabled`, the URL scheme is not `https`, the host is not on the allowlist, the
175
242
  # response is non-2xx, the body exceeds {URL_MAX_BYTES}, or the request times out
@@ -202,8 +269,53 @@ module Rigor
202
269
  Cache::Descriptor.new(files: files, configs: configs, globs: globs)
203
270
  end
204
271
 
272
+ # #1558 — records every row of `descriptor` as though this boundary had observed it. The caller is
273
+ # {Plugin::Base#cache_for}: a producer served from its own cache ran no block, so without the replay
274
+ # the value's inputs reached neither the ADR-45 run-result descriptor nor the dependency descriptor of
275
+ # a producer computing around it, and both went on validating fresh after those inputs changed.
276
+ #
277
+ # The rows are the ones a fresh hit just validated, so they describe the current filesystem, and they
278
+ # are merged by the precedence the live recorders use. A content row (`:stat` / `:digest` / `:mtime`)
279
+ # replaces an existence row for its path, as {#read_file} does after a probe. Any other collision
280
+ # keeps the row already here: an existence row never displaces one, as in {#probe}, and between two
281
+ # content rows or two glob rows for one slot the live row carries the newer stat tuple, which keeps
282
+ # the next warm run on its stat fast path. The tables are keyed by path, glob slot and URL key, so
283
+ # replaying the same descriptor twice adds nothing. Row order reaches no cache key, because
284
+ # {Cache::Descriptor#to_canonical_hash} sorts every slot.
285
+ #
286
+ # The policy is not consulted: the rows were recorded under it when the entry was written, and replay
287
+ # reads no file. A {Cache::Descriptor::GlobEntry} keeps its `mode`, which is part of its slot key.
288
+ #
289
+ # ADR-45 WD2 (#1507) — every replayed row is also handed to the {.attributing} sink, the ones a held row
290
+ # wins over included, exactly as the live recorders hand over what each read observed: a producer first
291
+ # asked while a file is analysed (rigor-actionpack's `:controller_index`, rigor-rails-i18n's
292
+ # `:locale_index`) serves that file its inputs, and the `--incremental` slot must credit them to it. On a
293
+ # miss the replayed descriptor is the whole boundary's ({Plugin::Base#producer_dependency_descriptor}), so
294
+ # the file is also credited with rows other reads recorded; that over-credits, which only ever makes the
295
+ # slot decline sooner.
296
+ #
297
+ # @param descriptor — a dependency descriptor; only its `files`, `configs` and `globs` slots are read,
298
+ # the only slots a boundary records
299
+ def replay(descriptor)
300
+ @mutex.synchronize do
301
+ descriptor.files.each { |entry| replay_file_entry(entry) }
302
+ descriptor.configs.each { |entry| @config_entries[entry.key] ||= entry }
303
+ descriptor.globs.each { |entry| @glob_entries[entry.slot_key] ||= entry }
304
+ end
305
+ (descriptor.files + descriptor.globs).each { |entry| self.class.attribute(entry) }
306
+ nil
307
+ end
308
+
205
309
  private
206
310
 
311
+ # The {#replay} precedence for one file row; the caller holds `@mutex`.
312
+ def replay_file_entry(entry)
313
+ current = @file_entries[entry.path]
314
+ return if current && !(current.comparator == :exists && entry.comparator != :exists)
315
+
316
+ @file_entries[entry.path] = entry
317
+ end
318
+
207
319
  # ADR-45 WD1b (#613) — the shared body of {#file?} / {#directory?}.
208
320
  #
209
321
  # The probe answers TRUTHFULLY for every path, in or out of the trusted-read scope, and never raises:
@@ -222,7 +334,7 @@ module Rigor
222
334
  # reverse) → NOTHING, the same bound `#read_file` pins for `EISDIR`: the probe observed neither a
223
335
  # usable file nor an absence, and either row would misdescribe what the plugin saw.
224
336
  def probe(path)
225
- absolute = File.expand_path(path.to_s)
337
+ absolute = File.absolute_path(path.to_s)
226
338
  answer = yield(absolute)
227
339
  unless @policy.allow_read?(absolute)
228
340
  record_refusal(absolute)
@@ -247,6 +359,7 @@ module Rigor
247
359
  # A content row replaces an earlier existence row for the same path outright: the file appeared and
248
360
  # its bytes were consumed, and a content row validates existence as well as content.
249
361
  @mutex.synchronize { @file_entries[path] = entry }
362
+ self.class.attribute(entry)
250
363
  end
251
364
 
252
365
  # ADR-45 WD1 (#577) — the absence row for a probed-but-missing path. A content row already recorded
@@ -263,6 +376,7 @@ module Rigor
263
376
  def record_absence_entry(path)
264
377
  entry = Cache::Descriptor::FileEntry.absent(path: path)
265
378
  @mutex.synchronize { @file_entries[path] ||= entry }
379
+ self.class.attribute(entry)
266
380
  end
267
381
 
268
382
  # ADR-45 WD1b (#613) — the presence row: fresh while the probed path exists, stale once it is gone.
@@ -273,14 +387,17 @@ module Rigor
273
387
  def record_presence_entry(path)
274
388
  entry = Cache::Descriptor::FileEntry.present(path: path)
275
389
  @mutex.synchronize { @file_entries[path] ||= entry }
390
+ self.class.attribute(entry)
276
391
  end
277
392
 
278
- # ADR-45 WD1c (#629) — the listing row for {#list_directory}, deduplicated per (root, pattern) slot.
393
+ # ADR-45 WD1c (#629) — the listing row for {#list_directory} (and the `:names` row for {#glob}),
394
+ # deduplicated per (root, pattern, mode) slot.
279
395
  # Recomputed on each call rather than `||=`d, so a directory listed again after a mid-run mutation
280
396
  # carries the signature of the state the LAST reader saw.
281
- def record_glob_entry(root, pattern)
282
- entry = Cache::Descriptor::GlobEntry.compute(root: root, pattern: pattern)
397
+ def record_glob_entry(root, pattern, mode: :stat)
398
+ entry = Cache::Descriptor::GlobEntry.compute(root: root, pattern: pattern, mode: mode)
283
399
  @mutex.synchronize { @glob_entries[entry.slot_key] = entry }
400
+ self.class.attribute(entry)
284
401
  end
285
402
 
286
403
  # Issue #959 — records that `path` fell outside every {TrustPolicy} read root, for {#refusal_summary}.
@@ -47,7 +47,7 @@ module Rigor
47
47
  # build resolves the same root and skips the write — #414), and reading an unshareable value out of
48
48
  # a module ivar from a non-main Ractor is the same `Ractor::IsolationError` as writing one.
49
49
  def target_bundle_root=(root)
50
- @target_bundle_root = root.nil? ? nil : File.expand_path(root.to_s).freeze
50
+ @target_bundle_root = root.nil? ? nil : File.absolute_path(root.to_s).freeze
51
51
  end
52
52
 
53
53
  # Requires `feature`, falling back to the analyzed project's bundler install tree. The fallback
@@ -42,8 +42,20 @@ module Rigor
42
42
  # `Nominal[Grape::Endpoint]` (the block is `instance_eval`'d on an instance of that class, e.g. a
43
43
  # route body on `Grape::Endpoint`), and `"singleton(Grape::API::Instance)"` binds
44
44
  # `Singleton[Grape::API::Instance]` (the block is `instance_eval`'d on that *class object*, e.g.
45
- # a `namespace` body on `Grape::API::Instance`). Reserved Symbol names (`:receiver_singleton`,
46
- # `:dsl_recorder`) remain unaccepted.
45
+ # a `namespace` body on `Grape::API::Instance`). `:lexical` (issue #1667) leaves `self` as the caller's:
46
+ # the method runs the block where it was written (`block.refined(M).call`), so the entry only declares
47
+ # `refinements:`. Reserved Symbol names (`:receiver_singleton`, `:dsl_recorder`) remain unaccepted.
48
+ # - `refinements` — Array of fully-qualified module-name Strings (default `[]`, issue #1667, ADR-121 WD5).
49
+ # The method runs the block under `Proc#refined` with these modules (`instance_exec(&block.refined(M))`),
50
+ # so they are appended to the block body's in-effect refinements after its lexical list, in the declared
51
+ # order (the last one wins). Nested blocks inherit them. A module that refines nothing the project or a
52
+ # gem's source inference makes visible contributes nothing.
53
+ #
54
+ # ## Matching
55
+ #
56
+ # A `:receiver_instance` or `singleton(...)` entry matches `Singleton[X]` receivers only (a class-level DSL
57
+ # call). A named instance binding (`"Foo::Bar"`) and a `:lexical` entry also match `Nominal[X]` receivers:
58
+ # a `:lexical` method is as often an instance method (`ActiveRecord::Relation#where`) as a class-level one.
47
59
  #
48
60
  # ## Ractor-shareability
49
61
  #
@@ -51,7 +63,8 @@ module Rigor
51
63
  # mutable array does not leak into the value. `Ractor.shareable?` returns true after `#initialize`.
52
64
  class BlockAsMethod
53
65
  SELF_TYPE_RECEIVER_INSTANCE = :receiver_instance
54
- VALID_SELF_TYPES = [SELF_TYPE_RECEIVER_INSTANCE].freeze
66
+ SELF_TYPE_LEXICAL = :lexical
67
+ VALID_SELF_TYPES = [SELF_TYPE_RECEIVER_INSTANCE, SELF_TYPE_LEXICAL].freeze
55
68
 
56
69
  CLASS_NAME_PATTERN = /[A-Z]\w*(?:::[A-Z]\w*)*/
57
70
  # `self_type: "Foo::Bar"` — the block is `instance_eval`'d on an instance of `Foo::Bar`.
@@ -60,16 +73,18 @@ module Rigor
60
73
  # object itself (Grape's `namespace`/`route_param` bodies run on `Grape::API::Instance`).
61
74
  SINGLETON_SELF_TYPE_PATTERN = /\Asingleton\((#{CLASS_NAME_PATTERN})\)\z/
62
75
 
63
- attr_reader :receiver_constraint, :method_names, :self_type
76
+ attr_reader :receiver_constraint, :method_names, :self_type, :refinements
64
77
 
65
- def initialize(receiver_constraint:, method_names:, self_type: SELF_TYPE_RECEIVER_INSTANCE)
78
+ def initialize(receiver_constraint:, method_names:, self_type: SELF_TYPE_RECEIVER_INSTANCE, refinements: [])
66
79
  validate_receiver_constraint!(receiver_constraint)
67
80
  validate_method_names!(method_names)
68
81
  validate_self_type!(self_type)
82
+ validate_refinements!(refinements)
69
83
 
70
84
  @receiver_constraint = receiver_constraint.dup.freeze
71
85
  @method_names = method_names.map(&:to_sym).freeze
72
86
  @self_type = self_type.is_a?(String) ? self_type.dup.freeze : self_type
87
+ @refinements = refinements.map { |name| name.dup.freeze }.freeze
73
88
  freeze
74
89
  end
75
90
 
@@ -77,7 +92,8 @@ module Rigor
77
92
  {
78
93
  "receiver_constraint" => receiver_constraint,
79
94
  "method_names" => method_names.map(&:to_s),
80
- "self_type" => self_type.to_s
95
+ "self_type" => self_type.to_s,
96
+ "refinements" => refinements
81
97
  }
82
98
  end
83
99
 
@@ -85,12 +101,18 @@ module Rigor
85
101
  other.is_a?(BlockAsMethod) &&
86
102
  receiver_constraint == other.receiver_constraint &&
87
103
  method_names == other.method_names &&
88
- self_type == other.self_type
104
+ self_type == other.self_type &&
105
+ refinements == other.refinements
89
106
  end
90
107
  alias eql? ==
91
108
 
92
109
  def hash
93
- [receiver_constraint, method_names, self_type].hash
110
+ [receiver_constraint, method_names, self_type, refinements].hash
111
+ end
112
+
113
+ # Whether the block keeps the caller's `self` (`self_type: :lexical`).
114
+ def lexical_self?
115
+ self_type == SELF_TYPE_LEXICAL
94
116
  end
95
117
 
96
118
  # The class name a String `self_type` binds `self` to — `"Foo::Bar"` for both the plain and the
@@ -118,6 +140,12 @@ module Rigor
118
140
  self_type.is_a?(String) && !singleton_binding?
119
141
  end
120
142
 
143
+ # Whether `Nominal[X]` receivers match, beside `Singleton[X]` ones: a named instance binding, or a
144
+ # `:lexical` entry.
145
+ def matches_instance_receivers?
146
+ named_instance_binding? || lexical_self?
147
+ end
148
+
121
149
  private
122
150
 
123
151
  def validate_receiver_constraint!(value)
@@ -143,6 +171,21 @@ module Rigor
143
171
  end
144
172
  end
145
173
 
174
+ def validate_refinements!(refinements)
175
+ unless refinements.is_a?(Array)
176
+ raise ArgumentError,
177
+ "Plugin::Macro::BlockAsMethod#refinements must be an Array, got #{refinements.inspect}"
178
+ end
179
+
180
+ refinements.each do |name|
181
+ next if name.is_a?(String) && name.match?(NAMED_SELF_TYPE_PATTERN)
182
+
183
+ raise ArgumentError,
184
+ "Plugin::Macro::BlockAsMethod#refinements entries must be module-name Strings ('Foo::Bar'), " \
185
+ "got #{name.inspect}"
186
+ end
187
+ end
188
+
146
189
  def validate_self_type!(self_type)
147
190
  return if VALID_SELF_TYPES.include?(self_type)
148
191
  return if self_type.is_a?(String) &&
@@ -36,7 +36,7 @@ module Rigor
36
36
 
37
37
  @trusted_gems = trusted_gems.map { |g| g.to_s.dup.freeze }.uniq.sort.freeze
38
38
  @allowed_read_roots = allowed_read_roots
39
- .map { |path| File.expand_path(path).freeze }
39
+ .map { |path| File.absolute_path(path).freeze }
40
40
  .uniq
41
41
  .sort
42
42
  .freeze
@@ -46,10 +46,10 @@ module Rigor
46
46
  end
47
47
 
48
48
  # @return true when the absolute path falls inside any allowed read root. Symlinks are
49
- # resolved through `File.expand_path` only (no `realpath`); plugins with adversarial intent are out of
49
+ # resolved through `File.absolute_path` only (no `realpath`); plugins with adversarial intent are out of
50
50
  # scope per ADR-2.
51
51
  def allow_read?(path)
52
- absolute = File.expand_path(path.to_s)
52
+ absolute = File.absolute_path(path.to_s)
53
53
  @allowed_read_roots.any? { |root| inside?(absolute, root) }
54
54
  end
55
55
 
@@ -53,16 +53,37 @@ module Rigor
53
53
  # ADR-93 auto-wired `rigor-rbs-inline` entry when the library is resolvable).
54
54
  # @param source_files — the file(s) the command inspects. Threaded so each loaded plugin's
55
55
  # `source_rbs_synthesizer` runs over them at env-build time; an empty list contributes no synthesized RBS.
56
- def build(configuration:, source_files:)
56
+ # @param feature_sources — path => source text the required-feature scan reads in place of that file (an
57
+ # editor buffer bound with `--tmp-file` / `--instead-of`).
58
+ def build(configuration:, source_files:, feature_sources: {})
59
+ features = required_features(configuration, source_files, sources: feature_sources)
57
60
  Environment.for_project(
58
61
  libraries: configuration.libraries,
59
62
  signature_paths: configuration.signature_paths,
60
63
  plugin_registry: load_plugin_registry(configuration),
61
64
  source_files: source_files,
65
+ required_features: features,
62
66
  **dependency_discovery_options(configuration)
63
67
  )
64
68
  rescue StandardError
65
- bare(configuration)
69
+ bare(configuration, features)
70
+ end
71
+
72
+ # Issue #1700 — the {Environment::RequiredFeatures} the environment gates on: the configured paths plus
73
+ # `extra_files` (the file a probe inspects, which may lie outside them), the same set `rigor check` scans
74
+ # whichever files it is given, so a position types as `rigor check` types it.
75
+ def required_features(configuration, extra_files = [], sources: {})
76
+ Environment::RequiredFeatures.for_configuration(configuration, extra_files, sources: sources)
77
+ end
78
+
79
+ # The required-feature scan's view of an editor buffer (`--tmp-file` / `--instead-of`): its logical path
80
+ # read from its physical file. Empty without a buffer or when the file cannot be read.
81
+ def buffer_sources(buffer)
82
+ return {} if buffer.nil?
83
+
84
+ { buffer.logical_path => File.binread(buffer.physical_path) }
85
+ rescue SystemCallError
86
+ {}
66
87
  end
67
88
 
68
89
  # The dependency-discovery axes `check` reads off the configuration, as a keyword hash. Extracted so the
@@ -116,22 +137,24 @@ module Rigor
116
137
 
117
138
  # The first fail-soft floor: no plugin tier and no synthesized RBS, but still the project's own dependency
118
139
  # sources — dropping those is what issue #821 was, so a plugin-loading failure must not cost them.
119
- def bare(configuration)
140
+ def bare(configuration, features = required_features(configuration))
120
141
  Environment.for_project(
121
142
  libraries: configuration.libraries,
122
143
  signature_paths: configuration.signature_paths,
144
+ required_features: features,
123
145
  **dependency_discovery_options(configuration)
124
146
  )
125
147
  rescue StandardError
126
- minimal(configuration)
148
+ minimal(configuration, features)
127
149
  end
128
150
 
129
151
  # The last floor: RBS core plus the project's own signature paths, which cannot fail on anything outside
130
152
  # the project itself.
131
- def minimal(configuration)
153
+ def minimal(configuration, features = [])
132
154
  Environment.for_project(
133
155
  libraries: configuration.libraries,
134
- signature_paths: configuration.signature_paths
156
+ signature_paths: configuration.signature_paths,
157
+ required_features: features
135
158
  )
136
159
  end
137
160
  end
@@ -17,8 +17,8 @@ module Rigor
17
17
  #
18
18
  # The answer (issue #260's recorded decision) is one table set, built once, threaded to both sides: the
19
19
  # {Mutator} base scope and {DiagnosticOracle}'s runner seam. This module builds that table set — the FULL
20
- # discovery bundle (`discovered_def_nodes`, `discovered_methods`, superclasses, includes, …), not just the
21
- # class-identity slice site selection needed, because resolving `find` on `singleton(Account)` is what makes
20
+ # discovery bundle (`discovered_def_nodes`, `discovered_methods`, superclasses, includes, extends, …), not just
21
+ # the class-identity slice site selection needed, because resolving `find` on `singleton(Account)` is what makes
22
22
  # the site killable.
23
23
  #
24
24
  # The shape is a plain Hash of {Scope::DiscoveryIndex} slot names, which is exactly what both consumers take:
@@ -84,9 +84,10 @@ module Rigor
84
84
  # collectors driven over the same tree) that {Analysis::Runner::ProjectPrePasses#discover} uses. Empty
85
85
  # tables are dropped so an empty seed stays `{}` and every consumer's "seed nothing" branch keeps working.
86
86
  #
87
- # `discovered_class_sources` is deliberately NOT carried: the runner itself seeds it only under dependency
88
- # recording (it is read by the ancestry accessors solely to record cross-file edges), and Tier 2 never
89
- # records.
87
+ # `discovered_class_sources` is carried as the runner carries it on every run: Tier 2 never records edges,
88
+ # but an ancestor-order reader asks whether a class is declared in more than one file. The mixin tables
89
+ # (`discovered_includes`, `discovered_prepends`, `discovered_extends`) ride the same fold, so a singleton
90
+ # `extend` reads the same from a per-file scope under coverage and protection as it does under `check`.
90
91
  def discovery_tables(paths)
91
92
  seed_tables(Inference::ScopeIndexer.discovered_project_index_for_paths(paths))
92
93
  end
@@ -99,19 +100,27 @@ module Rigor
99
100
  tables = { discovered_classes: index.fetch(:classes) }
100
101
  %i[
101
102
  def_nodes def_nestings singleton_def_nodes def_sources singleton_def_sources superclasses includes
102
- prepends
103
+ extends prepends unpositioned_mixins class_sources
103
104
  method_visibilities methods parameter_envelopes data_member_layouts struct_member_layouts
104
105
  deferred_ranges refinements global_write_census
105
106
  ].each do |slot|
106
107
  tables[seed_key(slot)] = def_index.fetch(slot)
107
108
  end
108
- tables.reject { |_, table| table.nil? || table.empty? }
109
+ # ADR-119 WD1 — the pairs travel as one: an unpaired empty table is dropped here, a pair only when both
110
+ # halves are empty (`compact_pairs`), so a non-empty sibling is never stranded by an empty member.
111
+ tables.merge!(def_index.fetch(:siblings))
112
+ Scope::DiscoveryIndex.compact_pairs(
113
+ tables.reject { |name, table| !Scope::DiscoveryIndex.paired?(name) && (table.nil? || table.empty?) }
114
+ )
109
115
  end
110
116
 
117
+ UNPREFIXED_SLOTS = %i[data_member_layouts struct_member_layouts unpositioned_mixins].freeze
118
+ private_constant :UNPREFIXED_SLOTS
119
+
111
120
  # The def-index slot names match the {Scope::DiscoveryIndex} ones under a `discovered_` prefix, except the
112
- # two member-layout tables, which carry the same name on both sides.
121
+ # two member-layout tables and the unpositioned-mixin table, which carry the same name on both sides.
113
122
  def seed_key(slot)
114
- slot.to_s.end_with?("member_layouts") ? slot : :"discovered_#{slot}"
123
+ UNPREFIXED_SLOTS.include?(slot) ? slot : :"discovered_#{slot}"
115
124
  end
116
125
  end
117
126
  end
@@ -140,7 +140,7 @@ module Rigor
140
140
  end
141
141
 
142
142
  def snapshot_root_candidates(roots, configured)
143
- [roots, Array(roots).map { |path| File.expand_path(path) },
143
+ [roots, Array(roots).map { |path| File.absolute_path(path) },
144
144
  configured, Array(configured).map { |path| relative_to_pwd(path) }]
145
145
  .compact.reject(&:empty?).uniq
146
146
  end
@@ -148,8 +148,8 @@ module Rigor
148
148
  # `path` rendered relative to the working directory when it lies underneath it; unchanged otherwise.
149
149
  # Shared by the root-candidate list and the per-file snapshot lookup, which face the same skew.
150
150
  def relative_to_pwd(path)
151
- absolute = File.expand_path(path)
152
- prefix = "#{File.expand_path(Dir.pwd)}#{File::SEPARATOR}"
151
+ absolute = File.absolute_path(path)
152
+ prefix = "#{File.absolute_path(Dir.pwd)}#{File::SEPARATOR}"
153
153
  absolute.start_with?(prefix) ? absolute.delete_prefix(prefix) : path.to_s
154
154
  end
155
155
 
@@ -220,7 +220,7 @@ module Rigor
220
220
  def patched_rows(patched)
221
221
  patched.by_key.map do |(class_name, method_name, kind), entry|
222
222
  [class_name.to_s, method_name.to_s, kind.to_s, entry.source_path.to_s, entry.source_line,
223
- entry.return_type&.describe(:short).to_s]
223
+ entry.return_type&.describe(:short).to_s, entry.alias_of.to_s]
224
224
  end.sort
225
225
  end
226
226
 
@@ -366,7 +366,7 @@ module Rigor
366
366
  end
367
367
 
368
368
  def snippet(loc)
369
- text = loc.slice.gsub(/\s+/, " ")
369
+ text = loc.slice.scrub.gsub(/\s+/, " ")
370
370
  text.length > 30 ? "#{text[0, 27]}..." : text
371
371
  end
372
372
  end