rigortype 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/string_scanner.rbs +6 -5
  15. data/docs/handbook/01-getting-started.md +22 -34
  16. data/docs/handbook/03-narrowing.md +2 -1
  17. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  18. data/docs/handbook/06-classes.md +19 -10
  19. data/docs/handbook/07-rbs-and-extended.md +76 -101
  20. data/docs/handbook/08-understanding-errors.md +114 -246
  21. data/docs/handbook/09-plugins.md +54 -144
  22. data/docs/handbook/11-sig-gen.md +11 -1
  23. data/docs/handbook/README.md +5 -3
  24. data/docs/handbook/appendix-liskov.md +4 -2
  25. data/docs/handbook/appendix-mypy.md +4 -3
  26. data/docs/handbook/appendix-phpstan.md +14 -7
  27. data/docs/handbook/appendix-steep.md +4 -2
  28. data/docs/handbook/appendix-type-theory.md +3 -1
  29. data/docs/install.md +14 -6
  30. data/docs/manual/01-installation.md +98 -4
  31. data/docs/manual/02-cli-reference.md +81 -12
  32. data/docs/manual/03-configuration.md +34 -2
  33. data/docs/manual/04-diagnostics.md +59 -4
  34. data/docs/manual/06-baseline.md +35 -1
  35. data/docs/manual/07-plugins.md +4 -4
  36. data/docs/manual/08-skills.md +6 -1
  37. data/docs/manual/09-editor-integration.md +7 -6
  38. data/docs/manual/11-ci.md +51 -0
  39. data/docs/manual/12-caching.md +65 -0
  40. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  41. data/docs/manual/plugins/rigor-devise.md +4 -2
  42. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  43. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  45. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  46. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  47. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  48. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  49. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  50. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  51. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  52. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  53. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +112 -0
  54. data/lib/rigor/analysis/check_rules.rb +581 -98
  55. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  56. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  57. data/lib/rigor/analysis/incremental_session.rb +456 -51
  58. data/lib/rigor/analysis/path_expansion.rb +42 -0
  59. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  60. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  61. data/lib/rigor/analysis/run_cache_key.rb +78 -0
  62. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  63. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  64. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  65. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  66. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  67. data/lib/rigor/analysis/runner.rb +344 -68
  68. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  69. data/lib/rigor/analysis/worker_session.rb +30 -2
  70. data/lib/rigor/bleeding_edge.rb +37 -8
  71. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  72. data/lib/rigor/cache/descriptor.rb +88 -28
  73. data/lib/rigor/cache/file_digest.rb +158 -0
  74. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  75. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  76. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  77. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  78. data/lib/rigor/cache/store.rb +132 -27
  79. data/lib/rigor/ci_detector.rb +90 -0
  80. data/lib/rigor/cli/annotate_command.rb +15 -9
  81. data/lib/rigor/cli/baseline_command.rb +4 -1
  82. data/lib/rigor/cli/check_command.rb +223 -32
  83. data/lib/rigor/cli/check_invocation.rb +84 -0
  84. data/lib/rigor/cli/coverage_command.rb +6 -1
  85. data/lib/rigor/cli/docs_command.rb +0 -30
  86. data/lib/rigor/cli/doctor_command.rb +169 -31
  87. data/lib/rigor/cli/lsp_command.rb +5 -0
  88. data/lib/rigor/cli/mcp_command.rb +5 -0
  89. data/lib/rigor/cli/plugins_command.rb +36 -6
  90. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  91. data/lib/rigor/cli/probe_environment.rb +85 -0
  92. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  93. data/lib/rigor/cli/skill_command.rb +22 -33
  94. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  95. data/lib/rigor/cli/skill_describe.rb +75 -9
  96. data/lib/rigor/cli/trace_command.rb +5 -6
  97. data/lib/rigor/cli/type_of_command.rb +9 -8
  98. data/lib/rigor/cli/type_scan_command.rb +8 -8
  99. data/lib/rigor/cli.rb +15 -4
  100. data/lib/rigor/config_audit.rb +48 -2
  101. data/lib/rigor/configuration/severity_profile.rb +31 -0
  102. data/lib/rigor/configuration.rb +156 -8
  103. data/lib/rigor/environment/default_libraries.rb +36 -0
  104. data/lib/rigor/environment/rbs_loader.rb +164 -5
  105. data/lib/rigor/environment.rb +29 -28
  106. data/lib/rigor/inference/budget_trace.rb +77 -2
  107. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  108. data/lib/rigor/inference/def_handle.rb +23 -0
  109. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  110. data/lib/rigor/inference/def_return_typer.rb +2 -1
  111. data/lib/rigor/inference/expression_typer.rb +361 -113
  112. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  113. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  114. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  115. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  116. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +17 -1
  117. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  118. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  119. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  120. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  121. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  122. data/lib/rigor/inference/mutation_widening.rb +42 -8
  123. data/lib/rigor/inference/narrowing.rb +127 -17
  124. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  125. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  126. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  127. data/lib/rigor/inference/scope_indexer.rb +451 -36
  128. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  129. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  130. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  131. data/lib/rigor/inference/void_origin.rb +25 -0
  132. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  133. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  134. data/lib/rigor/language_server/buffer_table.rb +46 -6
  135. data/lib/rigor/language_server/completion_provider.rb +2 -1
  136. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
  137. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  138. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  139. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  140. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  141. data/lib/rigor/language_server/server.rb +19 -9
  142. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  143. data/lib/rigor/language_server.rb +1 -0
  144. data/lib/rigor/plugin/base.rb +38 -24
  145. data/lib/rigor/plugin/inflector.rb +12 -3
  146. data/lib/rigor/plugin/io_boundary.rb +5 -1
  147. data/lib/rigor/plugin/isolation.rb +81 -11
  148. data/lib/rigor/plugin/load_error.rb +10 -1
  149. data/lib/rigor/plugin/loader.rb +96 -14
  150. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  151. data/lib/rigor/plugin/registry.rb +32 -23
  152. data/lib/rigor/plugin.rb +26 -0
  153. data/lib/rigor/protection/mutator.rb +3 -2
  154. data/lib/rigor/reflection.rb +64 -0
  155. data/lib/rigor/runtime/jit.rb +128 -0
  156. data/lib/rigor/scope/discovery_index.rb +12 -2
  157. data/lib/rigor/scope.rb +122 -28
  158. data/lib/rigor/sig_gen/classification.rb +5 -1
  159. data/lib/rigor/sig_gen/generator.rb +38 -6
  160. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  161. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  162. data/lib/rigor/sig_gen/renderer.rb +10 -0
  163. data/lib/rigor/sig_gen/write_result.rb +9 -4
  164. data/lib/rigor/sig_gen/writer.rb +207 -49
  165. data/lib/rigor/source/node_children.rb +116 -0
  166. data/lib/rigor/source/node_locator.rb +3 -1
  167. data/lib/rigor/source/node_walker.rb +4 -2
  168. data/lib/rigor/source.rb +1 -0
  169. data/lib/rigor/type/difference.rb +28 -24
  170. data/lib/rigor/type/hash_shape.rb +34 -10
  171. data/lib/rigor/version.rb +1 -1
  172. data/lib/rigortype.rb +24 -0
  173. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  174. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  175. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  176. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  177. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  178. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  179. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  180. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  181. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  182. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  183. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  184. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  185. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  186. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  187. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  188. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  189. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  190. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  191. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  192. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  193. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  194. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  195. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  196. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  197. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  198. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  202. data/sig/prism_node_children.rbs +9 -0
  203. data/sig/rigor/cache.rbs +6 -0
  204. data/sig/rigor/inference/void_origin.rbs +18 -0
  205. data/sig/rigor/inference.rbs +7 -0
  206. data/sig/rigor/plugin/base.rbs +6 -6
  207. data/sig/rigor/reflection.rbs +1 -0
  208. data/sig/rigor/scope.rbs +12 -1
  209. data/skills/rigor-ci-setup/SKILL.md +10 -0
  210. data/skills/rigor-editor-setup/SKILL.md +11 -0
  211. data/skills/rigor-next-steps/SKILL.md +23 -2
  212. data/skills/rigor-plugin-author/SKILL.md +3 -3
  213. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  214. data/skills/rigor-plugin-review/SKILL.md +2 -2
  215. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  216. data/skills/rigor-project-init/SKILL.md +29 -3
  217. data/skills/rigor-project-init/references/01-detect.md +1 -1
  218. data/skills/rigor-project-init/references/02-configure.md +39 -5
  219. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  220. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  221. metadata +48 -26
  222. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "digest"
4
4
  require_relative "incremental"
5
+ require_relative "plugin_fact_fingerprint"
6
+ require_relative "../cache/file_digest"
5
7
  require_relative "../cache/incremental_snapshot"
6
8
  require_relative "../inference/scope_indexer"
7
9
 
@@ -21,23 +23,58 @@ module Rigor
21
23
  # `--verify-incremental` acceptance gate, here without disk persistence or CLI wiring (the cache is
22
24
  # in-process). It models the body tier only: an edit that adds / removes / moves a *file* is outside
23
25
  # the analyzed set it maintains and falls to a fresh {#baseline} (the structural tier is a later slice).
24
- class IncrementalSession
26
+ # The class-length budget is relaxed: this is one cohesive orchestrator of the incremental state
27
+ # (per-file diagnostics cache, the file-level / symbol-level / negative dependency graphs, and the ADR-85
28
+ # seed bundles), clearer read together than split across micro-classes that would all share the same ivars.
29
+ class IncrementalSession # rubocop:disable Metrics/ClassLength
25
30
  # The outcome of a {#recheck}: the merged diagnostics plus the file sets, so a caller (or the verify
26
- # gate) can report what was re-analyzed versus served from cache.
27
- Recheck = Data.define(:diagnostics, :changed, :affected, :reused)
31
+ # gate) can report what was re-analyzed versus served from cache. `added` / `removed` carry the
32
+ # structural delta so {#run_incremental} (ADR-87 WD3) can recognise a zero-change recheck and skip the
33
+ # unconditional snapshot rewrite.
34
+ Recheck = Data.define(:diagnostics, :changed, :added, :removed, :affected, :reused) do
35
+ # A recheck that changed, added, and removed nothing — the session state is byte-equivalent to the
36
+ # snapshot that was restored, so persisting it again would only rewrite identical bytes.
37
+ def no_change?
38
+ changed.empty? && added.empty? && removed.empty?
39
+ end
40
+ end
28
41
 
29
42
  # @param paths [Array<String>, nil] explicit analysis roots; nil (the default) uses the configuration's
30
43
  # `paths:`.
31
44
  # @param environment [Rigor::Environment, nil] optional shared environment to thread into each internal
32
45
  # Runner. Long-lived callers and specs can use this to avoid rebuilding the same RBS universe for
33
46
  # every baseline / recheck / oracle run.
34
- def initialize(configuration:, paths: nil, environment: nil)
47
+ # @param cache_store [Rigor::Cache::Store, nil] ADR-85 WD1 — the persistent cache each internal Runner
48
+ # exposes to the RBS-env and plugin-producer tiers. A cross-process `--incremental` recheck otherwise
49
+ # rebuilt a fresh runner with no store, so every plugin `#prepare` producer (the ADR-9/#74/ADR-60 WD3
50
+ # record-and-validate caches) recomputed per invocation — 86% of a Rails warm incremental. Threading
51
+ # the store lets those producers serve from disk. `nil` (the default) preserves the pre-#85 behaviour
52
+ # the specs assert; the whole-run ADR-45 result cache stays disabled on these runs
53
+ # (`Runner#run_result_cacheable?` excludes `record_dependencies` / `analyze_only`).
54
+ # @param plugin_requirer [#call, nil] optional gem-require hook threaded into each internal Runner
55
+ # (mirrors {Runner}'s parameter). nil (the default, and what the CLI passes) uses `Kernel.require`;
56
+ # embedders and specs inject a fake so a test plugin registers without touching the real load path.
57
+ # @param workers [Integer] ADR-46 — the resolved fork-pool worker count threaded into every internal
58
+ # analyzer, so a `--incremental` recheck's closure re-analysis parallelises exactly like the standard
59
+ # `check` path (the recon's audit: `--workers` / `RIGOR_RACTOR_WORKERS` / `parallel.workers:` were
60
+ # silently ignored because `build_runner` passed no `workers:`). 0 (the default, and what the specs and
61
+ # `--verify-incremental` gate use) keeps the sequential recording path bit-for-bit unchanged. The fork
62
+ # pool records each worker's cross-file reads and marshals them back (PoolCoordinator), so the
63
+ # dependency graph a pooled recheck rebuilds equals the sequential one.
64
+ def initialize(configuration:, paths: nil, environment: nil, cache_store: nil, plugin_requirer: nil,
65
+ workers: 0)
35
66
  @configuration = configuration
36
67
  @paths = paths
37
68
  @environment = environment
69
+ @cache_store = cache_store
70
+ @plugin_requirer = plugin_requirer
71
+ @workers = workers
72
+ # ADR-85 WD2 — per-file discovery seed bundles keyed by logical path. A cold baseline builds them; a
73
+ # warm recheck folds them (re-walking only changed files) and refreshes the set. Ride the snapshot.
74
+ @seed_bundles = {}
38
75
  @cache = {} # analyzed path => [Diagnostic]
39
76
  @sources = {} # analyzed path => Set<source path it read from>
40
- @digests = {} # analyzed path => content digest at last analysis
77
+ @digests = {} # analyzed path => ADR-87 packed stat-digest entry at last analysis
41
78
  @analyzed = [] # the project files analyzed last round
42
79
  @dependents = {} # inverted @sources (file-level)
43
80
  # ADR-46 slice 4 — symbol-granularity tracking.
@@ -53,6 +90,18 @@ module Rigor
53
90
  @missing = {} # consumer => Set<"kind:name"> it looked up and missed
54
91
  @negative_dependents = {} # "kind:name" => Set<consumer> (inverted @missing)
55
92
  @class_decls = {} # path => Set<qualified class name declared in the file>
93
+ # ADR-89 WD2 — per-def observed-key return summaries: [path, symbol] => { keys:, returns:, effects: }.
94
+ # Harvested from the ADR-84 return memo after each run; drives the behavioural-stability gate.
95
+ @return_summaries = {}
96
+ # ADR-88 WD1 — the plugin fact-surface digest computed for THIS invocation (nil until a
97
+ # `#run_incremental` pass runs / a plugin-free project) and the reporting flags a caller (the CLI
98
+ # banner + `--cache-stats`) reads after `#run_incremental`. `@last_runner` is the analysis runner the
99
+ # post-hoc fingerprint reads from; `@plugin_fact_reusable` is the computed decision object.
100
+ @plugin_fact_digest = nil
101
+ @opaque_plugin_ids = [].freeze
102
+ @fact_surface_invalidated = false
103
+ @last_runner = nil
104
+ @plugin_fact_reusable = nil
56
105
  end
57
106
 
58
107
  # The project files analyzed at the last baseline / recheck — the set a verify pass partitions and the
@@ -66,10 +115,13 @@ module Rigor
66
115
  def baseline
67
116
  runner = build_runner(record_dependencies: true)
68
117
  diagnostics = run_runner(runner).diagnostics
118
+ @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
69
119
  @analyzed = runner.analyzed_files
120
+ @seed_bundles = runner.seed_bundles # ADR-85 WD2 — the freshly built bundle set for the next run.
70
121
  absorb_dependency_graph(runner)
122
+ @return_summaries = runner.return_summaries # ADR-89 WD2 — the full-run behavioural surface.
71
123
  @cache = per_file(diagnostics)
72
- @digests = @analyzed.to_h { |path| [path, digest(path)] }
124
+ @digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
73
125
  diagnostics
74
126
  end
75
127
 
@@ -81,15 +133,17 @@ module Rigor
81
133
  current = current_files
82
134
  added = current - previous
83
135
  removed = previous - current
84
- changed = (current & previous).reject { |path| digest(path) == @digests[path] }
136
+ changed = changed_paths(current & previous)
85
137
  affected = affected_closure(changed, added, removed)
86
138
  analyze_set = affected & current
87
139
  runner = build_runner(analyze_only: analyze_set, record_dependencies: true)
88
140
  fresh = run_runner(runner).diagnostics
141
+ @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
89
142
  reused = (current & previous) - affected.to_a
90
143
  merged = fresh + reused.flat_map { |path| @cache[path] || [] }
91
144
  absorb(runner, fresh, current, analyze_set, removed)
92
- Recheck.new(diagnostics: merged, changed: changed.to_set, affected: affected, reused: reused.to_set)
145
+ Recheck.new(diagnostics: merged, changed: changed.to_set, added: added.to_set,
146
+ removed: removed.to_set, affected: affected, reused: reused.to_set)
93
147
  end
94
148
 
95
149
  # The frozen set of files a #recheck must re-analyse: the symbol/ancestry-granularity closure of the
@@ -99,19 +153,105 @@ module Rigor
99
153
  # provided). An added file has no before-state, so all its symbols / classes appear.
100
154
  def affected_closure(changed, added, removed)
101
155
  scan = changed + added
102
- new_fps = symbol_fingerprints_for(scan)
103
- new_class_decls = class_declarations_for(scan)
156
+ # Parse the changed / added set ONCE for the per-symbol fingerprints, the class declarations, AND the
157
+ # ADR-89 WD1 declaration signatures. They were separate `discovered_def_index_for_paths` passes over
158
+ # the same `scan` set — a duplicate re-parse of every changed file each recheck (recon §2 / the P6
159
+ # recheck-floor audit).
160
+ summary = scan.empty? ? nil : Inference::ScopeIndexer.scan_summary_for_paths(scan)
161
+ scan_index = summary && summary[:def_index]
162
+ declaration_signatures = (summary && summary[:declaration_signatures]) || {}
163
+ new_fps = symbol_fingerprints_from_index(scan_index)
164
+ new_class_decls = class_declarations_from_index(scan_index)
104
165
  changed_pairs = Incremental.changed_symbol_pairs(changed, @symbol_fingerprints, new_fps)
105
- base = if changed_pairs.any? || changed.any? { |f| @ancestry_dependents[f] }
106
- Incremental.affected_with_symbols(changed, changed_pairs, @symbol_dependents, @ancestry_dependents)
107
- else
108
- Incremental.affected(changed, @dependents)
109
- end
110
- closure = base | added.to_set | negative_affected(scan, new_fps, new_class_decls)
166
+ # ADR-89 WD1 a changed file whose DECLARATION signature (per-def parameter shape / visibility /
167
+ # ancestry / member layout / def line, bodies excluded) is byte-identical to the snapshot is
168
+ # declaration-stable: every cross-file fact its ancestry / file-level dependents consume is
169
+ # declaration-derived and therefore unchanged (a body edit is consumed only by SYMBOL dependents,
170
+ # governed below by `changed_pairs`), so those dependents are skipped. Only the declaration-UNSTABLE
171
+ # changed files contribute their ancestry / file-level dependents; the changed files themselves are
172
+ # always re-analysed (`changed.to_set`) to learn their own diagnostics. This generalises the B1
173
+ # comment-only gate (code-stable ⟹ declaration-stable) to same-line body edits.
174
+ unstable = declaration_unstable(changed, declaration_signatures)
175
+ # ADR-89 WD2 — a declaration-stable changed def whose behavioural surface (return type at every
176
+ # previously-observed call key + content-mutation effects) is unchanged is behaviourally stable: its
177
+ # symbol dependents' cached diagnostics stay valid, so drop them. `symbol_pairs` is `changed_pairs`
178
+ # minus those stable pairs, and only it (not `changed_pairs`) drives the symbol-dependent fan-out.
179
+ symbol_pairs = behaviourally_unstable_pairs(changed_pairs, unstable, scan_index)
180
+ base = dependents_base(unstable, symbol_pairs)
181
+ closure = base | changed.to_set | added.to_set | negative_affected(scan, new_fps, new_class_decls)
111
182
  removed.each { |path| closure |= @dependents[path] || Set.new }
112
183
  closure.freeze
113
184
  end
114
185
 
186
+ # The dependents contributed by the declaration-unstable changed files and the behaviourally-unstable
187
+ # symbol pairs: the ADR-46 slice-4 symbol-granular fan-out when either is present (ancestry deps of the
188
+ # unstable files + symbol deps of the changed pairs), else the coarse file-level fan-out.
189
+ def dependents_base(unstable, symbol_pairs)
190
+ if symbol_pairs.any? || unstable.any? { |f| @ancestry_dependents[f] }
191
+ Incremental.affected_with_symbols(unstable, symbol_pairs, @symbol_dependents, @ancestry_dependents)
192
+ else
193
+ Incremental.affected(unstable, @dependents)
194
+ end
195
+ end
196
+
197
+ # ADR-89 WD2 — `changed_pairs` minus the behaviourally-STABLE pairs whose symbol dependents may be
198
+ # skipped. A pair `[path, "Class#method"]` is a candidate when its file is declaration-stable (WD1), it
199
+ # carries a persisted return summary, and the (edited) def is GATE-ELIGIBLE — its only cross-file body
200
+ # surfaces are its return type and its content-mutation effects (no instance/class variable write, no
201
+ # `yield`, no implicit-self call whose transitive effect a caller could observe). For a candidate the
202
+ # session compares its content-mutation effects (pure AST) then re-evaluates its return at every
203
+ # persisted key (a runner probe); a pair passes only when BOTH are unchanged. Any missing summary,
204
+ # ineligible def, changed effect, changed return, or uncomputable key keeps the pair — the conservative
205
+ # direction the `--verify-incremental` and byte-identical recheck specs backstop.
206
+ def behaviourally_unstable_pairs(changed_pairs, unstable, scan_index)
207
+ return changed_pairs if @return_summaries.empty? || scan_index.nil?
208
+
209
+ candidates = changed_pairs.select do |pair|
210
+ !unstable.include?(pair.first) && @return_summaries.key?(pair) &&
211
+ gate_eligible_def?(scan_def_node(pair.last, scan_index))
212
+ end
213
+ return changed_pairs if candidates.empty?
214
+
215
+ effect_stable = candidates.select { |pair| effects_unchanged?(pair, scan_index) }
216
+ return changed_pairs if effect_stable.empty?
217
+
218
+ stable = return_stable_pairs(effect_stable, scan_index)
219
+ stable.empty? ? changed_pairs : (changed_pairs - stable)
220
+ end
221
+
222
+ # ADR-89 WD1 — the changed files whose ancestry / file-level dependents must STILL be re-checked: those
223
+ # not provably declaration-stable. A changed file is declaration-STABLE (its ancestry / file-level
224
+ # dependents skippable) when its current {ScopeIndexer.declaration_signature} matches the one stored in
225
+ # the snapshot's seed bundle — i.e. its edit changed no method signature, visibility, ancestry, member
226
+ # layout, def line, or method existence, so every cross-file DECLARATION fact its dependents consume is
227
+ # unchanged. Falls back to treating EVERY changed file as unstable (today's full closure) when a
228
+ # comment-ingesting plugin is loaded — such a plugin reads the very comments the signature ignores, so a
229
+ # comment edit it treats as a no-op could change a cross-file type. Sorbet sigs / dry-types includes are
230
+ # CODE, captured by ADR-88's plugin-fact fingerprint (WD3), so only comment-as-input plugins escape here.
231
+ def declaration_unstable(changed, declaration_signatures)
232
+ return changed if comment_ingesting_plugin_loaded?
233
+
234
+ changed.reject { |path| declaration_stable?(path, declaration_signatures[path]) }
235
+ end
236
+
237
+ def declaration_stable?(path, current_declaration_signature)
238
+ return false if current_declaration_signature.nil?
239
+
240
+ bundle = @seed_bundles[path]
241
+ return false if bundle.nil?
242
+
243
+ bundle[:declaration_signature] == current_declaration_signature
244
+ end
245
+
246
+ def comment_ingesting_plugin_loaded?
247
+ # Mirrors the plugin loader's gem-name resolution (`ProjectPrePasses#trusted_gem_name`): a String
248
+ # entry IS the gem name; a Hash entry names it under `"gem"` (or the manifest `"id"`).
249
+ @configuration.plugins.any? do |entry|
250
+ name = entry.is_a?(Hash) ? (entry["gem"] || entry["id"]) : entry
251
+ COMMENT_INGESTING_PLUGIN_IDS.include?(name.to_s)
252
+ end
253
+ end
254
+
115
255
  # The current project file set (cheap directory expansion, no analysis), used to detect files added /
116
256
  # removed since the last run.
117
257
  def current_files
@@ -139,27 +279,100 @@ module Rigor
139
279
  # when a snapshot was restored. A nil `fingerprint` (uncomputable inputs) disables persistence: a
140
280
  # plain full run.
141
281
  def run_incremental(snapshot:, fingerprint:)
142
- restored = fingerprint && snapshot.load(fingerprint: fingerprint)
143
- if restored
144
- restore(restored)
145
- diagnostics = recheck.diagnostics
146
- warm = true
147
- else
148
- diagnostics = baseline
149
- warm = false
282
+ # ADR-87 WD1 install the per-run digest table + recording instant + strict flag for the whole
283
+ # invocation so change-detection's stat-then-digest freshness (`#pack_digest` / `#stat_fresh?`) honours
284
+ # `cache.validation: digest` (and `RIGOR_STRICT_VALIDATION`, which the env-only path already sees) and
285
+ # shares one digest memo across change detection and the baseline/absorb re-pack. The inner
286
+ # `Runner#run` nests its own `with_run` for the analysis descriptors; nesting is safe (each restores).
287
+ Cache::FileDigest.with_run(strict: @configuration.cache_validation_strict?) do
288
+ restored = fingerprint && snapshot.load(fingerprint: fingerprint)
289
+ # ADR-88 WD1 — the plugin fact-surface fingerprint gates snapshot reuse the same way the global
290
+ # fingerprint gates the load: a plugin sig/catalog edit outside `signature_paths:` (a Sorbet `.rbi`)
291
+ # changes the types unchanged call sites resolve, without moving any analyzed file, so the global
292
+ # fingerprint stays fresh and the recheck would serve stale diagnostics. The fingerprint is computed
293
+ # POST-HOC from the analysis runner (which already ran `#prepare` and validated its producers) so the
294
+ # warm path pays no second `#prepare`; the decision is applied after the recheck. Opaque plugins
295
+ # (types with no fingerprint surface) make the snapshot un-reusable.
296
+ if restored
297
+ restore(restored)
298
+ result = recheck
299
+ adopt_plugin_fact_fingerprint
300
+ reuse = @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
301
+ if reuse
302
+ diagnostics = result.diagnostics
303
+ warm = true
304
+ # ADR-87 WD3 — a warm recheck that changed nothing leaves the session state byte-equivalent to the
305
+ # snapshot it restored, so skip the unconditional rewrite (209ms + 2 MB on gitlab per null recheck).
306
+ # A cold baseline always persists — there was no valid snapshot to reuse.
307
+ skip_save = result.no_change?
308
+ else
309
+ # The fact surface moved (a plugin sig/catalog edit) or a plugin is opaque: the cached-served
310
+ # files the recheck merged may be stale, so re-analyze the whole tree. The current fact-surface
311
+ # digest (from the recheck runner) is unchanged by the re-analysis, so it is kept for the save.
312
+ @fact_surface_invalidated = true
313
+ diagnostics = baseline
314
+ warm = false
315
+ skip_save = false
316
+ end
317
+ else
318
+ diagnostics = baseline
319
+ adopt_plugin_fact_fingerprint
320
+ warm = false
321
+ skip_save = false
322
+ end
323
+ snapshot.save(fingerprint: fingerprint, payload: to_payload) if fingerprint && !skip_save
324
+ [diagnostics, warm]
150
325
  end
151
- snapshot.save(fingerprint: fingerprint, payload: to_payload) if fingerprint
152
- [diagnostics, warm]
326
+ end
327
+
328
+ # ADR-88 WD1 — reporting hooks the CLI reads after {#run_incremental}. `fact_surface_invalidated?` is true
329
+ # when a valid snapshot was dropped for a fact-surface reason (a plugin sig/catalog edit, or an opaque
330
+ # plugin) rather than a cold miss. `opaque_plugin_ids` names the contributing-but-surfaceless plugins that
331
+ # force a full run every invocation.
332
+ attr_reader :opaque_plugin_ids
333
+
334
+ def fact_surface_invalidated?
335
+ @fact_surface_invalidated
153
336
  end
154
337
 
155
338
  private
156
339
 
340
+ # ADR-88 WD1 — capture this invocation's fact-surface fingerprint (from the last analysis runner) onto the
341
+ # reporting ivars + the `@plugin_fact_reusable` decision object.
342
+ def adopt_plugin_fact_fingerprint
343
+ fact = compute_plugin_fact_fingerprint
344
+ @plugin_fact_digest = fact.digest
345
+ @opaque_plugin_ids = fact.opaque_plugin_ids
346
+ @plugin_fact_reusable = fact
347
+ end
348
+
349
+ # ADR-88 WD1 — the plugin fact-surface fingerprint for this invocation. For a SEQUENTIAL run it is read
350
+ # post-hoc from the analysis runner's already-prepared registry (no second `#prepare`, producers already
351
+ # validated during analysis). A POOLED run's main process skips `#prepare`, so its registry carries no
352
+ # published facts — there it falls back to the always-sequential probe. Both paths compute the identical
353
+ # digest for a given fact surface, so the reuse decision is worker-count-independent (the parity spec
354
+ # asserts this).
355
+ def compute_plugin_fact_fingerprint
356
+ registry = @last_runner&.plugin_registry
357
+ if @workers.zero? && registry && !registry.empty?
358
+ PluginFactFingerprint.from_registry(registry)
359
+ else
360
+ PluginFactFingerprint.compute(
361
+ configuration: @configuration, cache_store: @cache_store, plugin_requirer: @plugin_requirer
362
+ )
363
+ end
364
+ end
365
+
157
366
  # Adopt a persisted snapshot's per-file state as this session's baseline (the warm-start path).
158
367
  def restore(payload)
159
368
  @analyzed = payload.analyzed
160
369
  @cache = payload.cache
161
370
  @sources = payload.sources
162
371
  @digests = payload.digests
372
+ # ADR-85 WD2 — restore the per-file discovery bundles if present (absent in a pre-#85 snapshot → empty,
373
+ # so the recheck's discovery re-walks every file: a cold-quality index, always sound). The SCHEMA bump
374
+ # makes a genuinely stale-shaped bundle unreadable rather than mis-folded.
375
+ @seed_bundles = payload.seed_bundles || {}
163
376
  @dependents = Incremental.invert(@sources)
164
377
  # ADR-46 slice 4 — restore symbol-granularity state if present in the payload (absent in snapshots
165
378
  # written before slice 4 → fall back to file-level dependents, which is always sound).
@@ -172,6 +385,7 @@ module Rigor
172
385
  # `--verify-incremental` backstops any residual under-capture, so it is never unsound).
173
386
  @missing = payload.missing || {}
174
387
  @class_decls = payload.class_decls || {}
388
+ @return_summaries = payload.return_summaries || {}
175
389
  @symbol_dependents = Incremental.invert_symbols(@symbol_sources)
176
390
  @ancestry_dependents = Incremental.invert(@ancestry_sources)
177
391
  @negative_dependents = Incremental.invert(@missing)
@@ -182,22 +396,59 @@ module Rigor
182
396
  cache: @cache, sources: @sources, digests: @digests, analyzed: @analyzed,
183
397
  symbol_sources: @symbol_sources, ancestry_sources: @ancestry_sources,
184
398
  symbol_fingerprints: @symbol_fingerprints, missing: @missing,
185
- class_decls: @class_decls
399
+ class_decls: @class_decls, seed_bundles: @seed_bundles,
400
+ plugin_fact_digest: @plugin_fact_digest,
401
+ return_summaries: marshal_safe_return_summaries
186
402
  )
187
403
  end
188
404
 
405
+ # ADR-89 WD2 — the return summaries filtered to Marshal-clean entries. A summary's `keys` hold live
406
+ # `Type` objects; the common carriers (Nominal, Constant, Union, shapes, Dynamic) Marshal, but a type
407
+ # holding a live AST node would raise and abort the WHOLE snapshot save (a cache must never break a
408
+ # run). Drop any entry that does not round-trip, so the snapshot always writes; a dropped entry just
409
+ # means that callee's dependents always re-check (the conservative direction).
410
+ def marshal_safe_return_summaries
411
+ @return_summaries.each_with_object({}) do |(key, summary), safe|
412
+ Marshal.dump(summary)
413
+ safe[key] = summary
414
+ rescue StandardError
415
+ next
416
+ end
417
+ end
418
+
189
419
  # Fold a #recheck's fresh results back into the cache + graph so the session is correct across
190
420
  # multiple edits: the analyzed set gets fresh diagnostics + digests + dependency edges, removed files
191
421
  # are evicted from every map, and the analyzed-file list advances to `current`.
192
422
  def absorb(runner, fresh, current, analyze_set, removed)
193
423
  removed.each { |path| forget(path) }
194
424
  @analyzed = current
425
+ # ADR-85 WD2 — the recheck's discovery folded the restored bundles and refreshed them (changed files
426
+ # re-walked, removed files dropped, added files built), so adopt the runner's current set wholesale.
427
+ @seed_bundles = runner.seed_bundles
195
428
  fresh_by_file = per_file(fresh)
196
429
  analyze_set.each do |path|
197
430
  @cache[path] = fresh_by_file[path] || []
198
- @digests[path] = digest(path)
431
+ @digests[path] = pack_digest(path)
199
432
  end
200
433
  absorb_dependency_graph(runner)
434
+ refresh_return_summaries(runner, analyze_set)
435
+ end
436
+
437
+ # ADR-89 WD2 — replace the behavioural summaries of every re-analyzed file with THIS run's harvest: drop
438
+ # the analyzed files' old summaries first, then fold the fresh ones. Dropping first is the soundness
439
+ # invariant — a summary for a file is valid only if it came from a run that analyzed that file. On the
440
+ # sequential path (the default + `--verify-incremental`) the harvest is complete, so a re-analyzed def's
441
+ # summary is simply refreshed. On a pooled recheck the callee bodies are evaluated in worker processes,
442
+ # so the main-process memo harvest is sparse; dropping first means a re-analyzed file with no fresh
443
+ # summary loses its stale one (WD2 then conservatively re-checks its dependents) rather than comparing a
444
+ # later edit against a pre-edit summary. Files NOT re-analyzed keep their (unchanged, valid) summaries.
445
+ # A def whose symbol dependents WD2 skipped is observed under fewer keys, so its refreshed summary may
446
+ # shrink — sound (fewer keys = more conservative) and self-correcting (an empty summary stops the gate
447
+ # firing, so the callers re-analyse and repopulate).
448
+ def refresh_return_summaries(runner, analyze_set)
449
+ analyzed = analyze_set.to_set
450
+ @return_summaries.reject! { |(path, _symbol), _| analyzed.include?(path) }
451
+ @return_summaries.merge!(runner.return_summaries)
201
452
  end
202
453
 
203
454
  # Evict a removed file from every per-file map so its stale diagnostics are never served and it drops
@@ -210,6 +461,8 @@ module Rigor
210
461
  @ancestry_sources.delete(path)
211
462
  @missing.delete(path)
212
463
  @symbol_fingerprints.delete(path)
464
+ # ADR-89 WD2 — drop every behavioural summary a removed file provided (keys are `[path, symbol]`).
465
+ @return_summaries.reject! { |(summary_path, _symbol), _| summary_path == path }
213
466
  # @class_decls is wholesale-replaced from the (removed-excluding)
214
467
  # pre-pass in absorb_dependency_graph, and is frozen, so no delete.
215
468
  end
@@ -233,28 +486,36 @@ module Rigor
233
486
  @class_decls = runner.class_declarations
234
487
  end
235
488
 
236
- # Compute per-symbol body fingerprints for `paths` via a quick indexing re-pass (Prism parse + def
237
- # extraction, no type inference). Returns a hash of the form `{ path => { "ClassName#method" =>
238
- # sha256_hex } }`. Used by {#recheck} to detect which symbols in a changed file actually changed, so
239
- # only their callers are added to the affected closure.
240
- def symbol_fingerprints_for(paths)
241
- return {} if paths.empty?
489
+ # Per-symbol body fingerprints from the pre-parsed `index` (the shared scan-summary def-index Prism
490
+ # parse + def extraction, no type inference). Returns `{ path => { "ClassName#method" => sha256_hex } }`
491
+ # with singleton methods under `"Class.method"` keys. Used by {#recheck} to detect which symbols in a
492
+ # changed file actually changed, so only their callers are added to the affected closure.
493
+ def symbol_fingerprints_from_index(index)
494
+ return {} if index.nil?
242
495
 
243
- index = Inference::ScopeIndexer.discovered_def_index_for_paths(paths)
244
- def_nodes = index[:def_nodes]
245
- def_sources = index[:def_sources]
246
496
  result = Hash.new { |h, k| h[k] = {} }
247
- def_sources.each do |class_name, methods|
497
+ fold_symbol_fingerprints(result, index[:def_sources], index[:def_nodes], "#")
498
+ # ADR-46 slice 4 (singleton) — mirror the instance fold over the singleton tables so a `def self.x`
499
+ # body edit yields a changed `"Class.method"` pair, matching the `Runner#symbol_fingerprints` key that
500
+ # the recorded singleton symbol edges (`singleton_def_for`) invert against.
501
+ fold_symbol_fingerprints(result, index[:singleton_def_sources], index[:singleton_def_nodes], ".")
502
+ result.transform_values(&:freeze).freeze
503
+ end
504
+
505
+ # Folds one `(sources, nodes)` table pair from the re-parse index into `result` under `separator`
506
+ # (`#` instance / `.` singleton). Nodes here are always LIVE (a fresh parse of the changed paths), so
507
+ # the fingerprint is the def's source slice; `sources` supplies the `"path:line"` a `Prism::Location`
508
+ # hides.
509
+ def fold_symbol_fingerprints(result, sources, nodes, separator)
510
+ sources.each do |class_name, methods|
248
511
  methods.each do |method_sym, path_line|
249
512
  path = path_line.split(":", 2).first
250
- node = def_nodes.dig(class_name, method_sym)
513
+ node = nodes.dig(class_name, method_sym)
251
514
  next unless node
252
515
 
253
- result[path]["#{class_name}##{method_sym}"] =
254
- Digest::SHA256.hexdigest(node.location.slice)
516
+ result[path]["#{class_name}#{separator}#{method_sym}"] = Digest::SHA256.hexdigest(node.location.slice)
255
517
  end
256
518
  end
257
- result.transform_values(&:freeze).freeze
258
519
  end
259
520
 
260
521
  # ADR-46 slice 3 — the consumers to re-check because a symbol that appeared in a changed file resolves
@@ -269,13 +530,11 @@ module Rigor
269
530
  Incremental.negative_closure(keys, @negative_dependents)
270
531
  end
271
532
 
272
- # The qualified class/module names declared in `paths`, via the same quick indexing re-pass
273
- # {#symbol_fingerprints_for} uses (Prism parse + declaration extraction, no inference). `{ path =>
274
- # Set<class name> }`.
275
- def class_declarations_for(paths)
276
- return {} if paths.empty?
533
+ # The qualified class/module names declared in the pre-parsed `index` (shared with
534
+ # {#symbol_fingerprints_from_index}, so the changed set is parsed once). `{ path => Set<class name> }`.
535
+ def class_declarations_from_index(index)
536
+ return {} if index.nil?
277
537
 
278
- index = Inference::ScopeIndexer.discovered_def_index_for_paths(paths)
279
538
  result = Hash.new { |hash, key| hash[key] = Set.new }
280
539
  index[:class_sources].each do |class_name, files|
281
540
  files.each { |file| result[file] << class_name }
@@ -283,16 +542,135 @@ module Rigor
283
542
  result.transform_values(&:freeze).freeze
284
543
  end
285
544
 
545
+ # ADR-89 WD2 — the (live, freshly parsed) def node for a `"Class#method"` / `"Class.method"` symbol in
546
+ # the changed-set scan index, or nil. Instance methods live under `def_nodes`, singleton under
547
+ # `singleton_def_nodes`, both keyed by class name then method Symbol.
548
+ def scan_def_node(symbol, scan_index)
549
+ singleton = !symbol.include?("#")
550
+ class_name, method_name = symbol.split(singleton ? "." : "#", 2)
551
+ return nil if method_name.nil?
552
+
553
+ table = singleton ? scan_index[:singleton_def_nodes] : scan_index[:def_nodes]
554
+ table.dig(class_name, method_name.to_sym)
555
+ end
556
+
557
+ # ADR-89 WD2 — true when the ONLY cross-file body surfaces of `node` a symbol dependent can consume are
558
+ # its return type and its content-mutation effects — so comparing exactly those two is a COMPLETE
559
+ # behavioural check. A def qualifies when its body writes no instance / class variable (an ivar
560
+ # definite-assignment a same-class caller consumes), does not `yield` (a block-parameter surface a
561
+ # caller passing a block consumes), and makes no implicit-self call (which could carry a transitive
562
+ # shared-state write). Deliberately conservative + purely syntactic: a def with a self-call or a
563
+ # shared-state write keeps its dependents (the general all-surfaces gate is deferred). Excludes nested
564
+ # `def`s (a different self) but walks blocks / lambdas (same self).
565
+ def gate_eligible_def?(node)
566
+ return false if node.nil? || !node.respond_to?(:body) || node.body.nil?
567
+
568
+ !body_has_ineligible_node?(node.body)
569
+ end
570
+
571
+ def body_has_ineligible_node?(node)
572
+ return false if node.nil? || node.is_a?(Prism::DefNode)
573
+
574
+ return true if INELIGIBLE_GATE_NODES.any? { |kind| node.is_a?(kind) }
575
+ return true if self_dispatch_call?(node)
576
+
577
+ node.rigor_each_child { |child| return true if body_has_ineligible_node?(child) }
578
+ false
579
+ end
580
+
581
+ def self_dispatch_call?(node)
582
+ node.is_a?(Prism::CallNode) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
583
+ end
584
+
585
+ # ADR-89 WD2 — node kinds whose presence in a def body means a caller may consume a body surface beyond
586
+ # return + content-mutation: any instance/class-variable write (ivar definite-assignment) and `yield`
587
+ # (block-parameter typing). Class variables are per-file (never seeded cross-file) but are included for
588
+ # a conservative margin; global writes are likewise per-file so are not listed.
589
+ INELIGIBLE_GATE_NODES = [
590
+ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode,
591
+ Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableOperatorWriteNode,
592
+ Prism::ClassVariableWriteNode, Prism::ClassVariableOrWriteNode,
593
+ Prism::ClassVariableAndWriteNode, Prism::ClassVariableOperatorWriteNode,
594
+ Prism::YieldNode
595
+ ].freeze
596
+ private_constant :INELIGIBLE_GATE_NODES
597
+
598
+ # ADR-89 WD2 — true when the current content-mutation effect set of `pair`'s (edited) def equals the one
599
+ # persisted in its return summary. Computed session-side from the fresh node (pure AST — no scope), so a
600
+ # caller's arg flooring is proven unchanged before the return re-evaluation. A nil / raised computation
601
+ # reads as CHANGED (keeps the pair).
602
+ def effects_unchanged?(pair, scan_index)
603
+ node = scan_def_node(pair.last, scan_index)
604
+ return false if node.nil?
605
+
606
+ current = content_effects_for(node)
607
+ !current.nil? && current == (@return_summaries[pair][:effects] || [])
608
+ end
609
+
610
+ def content_effects_for(node)
611
+ (@effects_evaluator ||= Inference::StatementEvaluator.new(scope: Scope.empty))
612
+ .content_mutated_parameter_positions(node)
613
+ rescue StandardError
614
+ nil
615
+ end
616
+
617
+ # ADR-89 WD2 — the subset of `pairs` whose (edited) def re-evaluates to the SAME return descriptor at
618
+ # every persisted key. Runs one runner probe (`evaluate_return_types` — builds the discovery + env once,
619
+ # no file analysis) over all candidate defs, then keeps a pair only when every key's re-evaluated
620
+ # descriptor is present (not a memo refusal) and equal to the persisted one. A probe failure yields an
621
+ # empty set (keep everything).
622
+ def return_stable_pairs(pairs, scan_index)
623
+ specs = pairs.filter_map { |pair| return_spec_for(pair, scan_index) }
624
+ return Set.new if specs.empty?
625
+
626
+ results = probe_return_types(specs)
627
+ specs.each_with_object(Set.new) do |spec, stable|
628
+ descriptors = results[[spec[:class_name], spec[:method_name], spec[:singleton]]]
629
+ expected = @return_summaries[spec[:pair]][:returns]
630
+ next if descriptors.nil? || descriptors.length != expected.length
631
+ next unless descriptors.each_with_index.all? { |d, i| !d.nil? && d == expected[i] }
632
+
633
+ stable << spec[:pair]
634
+ end
635
+ end
636
+
637
+ def return_spec_for(pair, scan_index)
638
+ path, symbol = pair
639
+ node = scan_def_node(symbol, scan_index)
640
+ return nil if node.nil?
641
+
642
+ singleton = !symbol.include?("#")
643
+ class_name, method_name = symbol.split(singleton ? "." : "#", 2)
644
+ { pair: pair, path: path, class_name: class_name, method_name: method_name.to_sym,
645
+ singleton: singleton, keys: @return_summaries[pair][:keys] }
646
+ end
647
+
648
+ def probe_return_types(specs)
649
+ build_runner.evaluate_return_types(@paths, specs)
650
+ rescue StandardError
651
+ {}
652
+ end
653
+
286
654
  TOP_LEVEL_KEY = Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
287
655
  private_constant :TOP_LEVEL_KEY
288
656
 
657
+ # B1 — plugin require-names that ingest COMMENT content as semantic input (inline-RBS reads `# @rbs` /
658
+ # `#:` annotations). B1's code fingerprint ignores comments, so a project configuring one of these opts
659
+ # OUT of the bundle-equality skip (a comment edit could change a cross-file type it contributes).
660
+ COMMENT_INGESTING_PLUGIN_IDS = %w[rigor-rbs-inline].freeze
661
+ private_constant :COMMENT_INGESTING_PLUGIN_IDS
662
+
289
663
  def negative_key_for(symbol)
290
664
  class_name, method = symbol.split("#", 2)
291
665
  class_name == TOP_LEVEL_KEY ? "toplevel:#{method}" : "method:#{symbol}"
292
666
  end
293
667
 
294
668
  def build_runner(**)
295
- Runner.new(configuration: @configuration, cache_store: nil, environment: @environment, **)
669
+ Runner.new(
670
+ configuration: @configuration, cache_store: @cache_store, environment: @environment,
671
+ plugin_requirer: @plugin_requirer, seed_bundles: @seed_bundles, collect_seed_bundles: true,
672
+ workers: @workers, **
673
+ )
296
674
  end
297
675
 
298
676
  # Run the runner over the session's explicit paths (or, when none were given, the configuration's
@@ -308,8 +686,35 @@ module Rigor
308
686
  diagnostics.group_by(&:path).slice(*@analyzed)
309
687
  end
310
688
 
311
- def digest(path)
312
- Digest::SHA256.hexdigest(File.read(path))
689
+ # ADR-87 WD1 — change detection over the candidate paths (files present in both the prior and current
690
+ # analyzed set). A candidate is UNCHANGED when its recorded stat entry ({#pack_digest}) validates via
691
+ # {Cache::FileDigest.stat_fresh?}: the file is stat-ed first and re-hashed ONLY when its
692
+ # `(size, mtime, ctime, inode)` tuple moved or the entry is racy — so an unchanged file hashes zero
693
+ # content bytes (the recon anomaly this closes: the old path SHA-256'd every file every recheck). A
694
+ # touch (moved stat, identical content) re-hashes once and validates fresh. Returns the changed subset.
695
+ def changed_paths(candidates)
696
+ candidates.reject { |path| stat_fresh?(path) }
697
+ end
698
+
699
+ # True when `path`'s recorded stat entry proves it unchanged since the last analysis. Any stat / parse
700
+ # failure (missing entry, unreadable / vanished file) reads as NOT fresh (→ re-analyse), preserving the
701
+ # prior `digest(path) != recorded` "changed" semantics for a file that cannot be validated.
702
+ def stat_fresh?(path)
703
+ entry = @digests[path]
704
+ return false if entry.nil?
705
+
706
+ Cache::FileDigest.stat_fresh?(path, entry)
707
+ rescue StandardError
708
+ false
709
+ end
710
+
711
+ # ADR-87 WD1 — the recorded freshness entry for `path`: its SHA-256 content digest packed with the stat
712
+ # tuple `(size, mtime_ns, ctime_ns, inode)` and the run's recording instant, so the NEXT recheck can
713
+ # validate it by a single `File::Stat` when the tuple has not moved. The SHA-256 digest stays the sole
714
+ # change authority — the stat tuple only decides whether it must be recomputed. "missing" when the file
715
+ # cannot be read / stat-ed (its prior sentinel, so a vanished file still compares unequal and re-analyses).
716
+ def pack_digest(path)
717
+ Cache::FileDigest.pack_stat(path, Cache::FileDigest.hexdigest(path)) || "missing"
313
718
  rescue StandardError
314
719
  "missing"
315
720
  end