rigortype 0.4.5 → 0.4.6

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +11 -2
  4. data/docs/manual/02-cli-reference.md +46 -7
  5. data/docs/manual/03-configuration.md +2 -1
  6. data/docs/manual/04-diagnostics.md +1 -1
  7. data/docs/manual/10-mcp-server.md +9 -1
  8. data/docs/manual/12-caching.md +8 -1
  9. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  10. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  11. data/docs/manual/plugins/rigor-devise.md +15 -0
  12. data/docs/manual/plugins/rigor-rspec.md +2 -1
  13. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  14. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  15. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  16. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -0
  17. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
  18. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  19. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  20. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  21. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +51 -0
  22. data/lib/rigor/analysis/check_rules.rb +93 -8
  23. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  24. data/lib/rigor/analysis/edit_replay.rb +67 -0
  25. data/lib/rigor/analysis/incremental.rb +37 -0
  26. data/lib/rigor/analysis/incremental_session.rb +175 -27
  27. data/lib/rigor/analysis/path_expansion.rb +41 -0
  28. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  29. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  30. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  31. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  32. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  33. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  34. data/lib/rigor/analysis/runner/pool_coordinator.rb +17 -4
  35. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  36. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  37. data/lib/rigor/analysis/runner.rb +156 -49
  38. data/lib/rigor/analysis/worker_session.rb +14 -2
  39. data/lib/rigor/cache/incremental_snapshot.rb +30 -7
  40. data/lib/rigor/cli/check_command.rb +93 -4
  41. data/lib/rigor/cli/diff_command.rb +2 -1
  42. data/lib/rigor/cli/lens_command.rb +5 -5
  43. data/lib/rigor/cli/mcp_command.rb +2 -1
  44. data/lib/rigor/cli/options.rb +20 -3
  45. data/lib/rigor/cli/plugins_command.rb +5 -0
  46. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  48. data/lib/rigor/cli/sig_gen_command.rb +46 -7
  49. data/lib/rigor/cli/type_of_command.rb +2 -1
  50. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  51. data/lib/rigor/config_audit.rb +16 -5
  52. data/lib/rigor/configuration.rb +14 -4
  53. data/lib/rigor/environment/rbs_loader.rb +115 -6
  54. data/lib/rigor/environment/required_features.rb +136 -29
  55. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  56. data/lib/rigor/environment.rb +4 -2
  57. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  58. data/lib/rigor/inference/deep_erasure.rb +138 -0
  59. data/lib/rigor/inference/def_return_typer.rb +159 -9
  60. data/lib/rigor/inference/definer_resolution.rb +17 -3
  61. data/lib/rigor/inference/error_info.rb +17 -3
  62. data/lib/rigor/inference/expression_typer.rb +62 -13
  63. data/lib/rigor/inference/global_write_census.rb +36 -1
  64. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  65. data/lib/rigor/inference/loop_value_record.rb +53 -0
  66. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +110 -9
  68. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  69. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  70. data/lib/rigor/inference/method_dispatcher.rb +46 -16
  71. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  72. data/lib/rigor/inference/module_function_state.rb +12 -0
  73. data/lib/rigor/inference/mutation_widening.rb +5 -2
  74. data/lib/rigor/inference/narrowing.rb +66 -5
  75. data/lib/rigor/inference/object_mixins.rb +13 -1
  76. data/lib/rigor/inference/project_seed_tables.rb +2 -1
  77. data/lib/rigor/inference/project_yielder.rb +128 -0
  78. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  79. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  80. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  81. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  82. data/lib/rigor/inference/scope_indexer.rb +480 -103
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +18 -6
  84. data/lib/rigor/inference/statement_evaluator.rb +658 -90
  85. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  86. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  87. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  88. data/lib/rigor/language_server/buffer_table.rb +6 -0
  89. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  90. data/lib/rigor/language_server/project_context.rb +59 -3
  91. data/lib/rigor/language_server/server.rb +5 -3
  92. data/lib/rigor/mcp/server.rb +9 -1
  93. data/lib/rigor/plugin/base.rb +26 -11
  94. data/lib/rigor/plugin/manifest.rb +29 -3
  95. data/lib/rigor/plugin/registry.rb +38 -0
  96. data/lib/rigor/protection/discovery_seed.rb +57 -2
  97. data/lib/rigor/rooted_glob.rb +10 -1
  98. data/lib/rigor/scope.rb +14 -5
  99. data/lib/rigor/sig_gen/generator.rb +308 -37
  100. data/lib/rigor/sig_gen/observation_collector.rb +4 -1
  101. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  102. data/lib/rigor/sig_gen/renderer.rb +6 -3
  103. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  104. data/lib/rigor/type/combinator.rb +4 -3
  105. data/lib/rigor/type/constant.rb +45 -3
  106. data/lib/rigor/type/hash_shape.rb +14 -9
  107. data/lib/rigor/type/struct_instance.rb +3 -2
  108. data/lib/rigor/version.rb +1 -1
  109. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +60 -43
  110. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  111. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  112. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  113. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  114. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +6 -5
  115. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  116. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  117. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  118. data/sig/rigor/analysis/baseline.rbs +3 -2
  119. data/sig/rigor/effects/file_collection.rbs +5 -6
  120. data/sig/rigor/effects/registry.rbs +8 -6
  121. data/sig/rigor/effects/summary.rbs +6 -5
  122. data/sig/rigor/environment.rbs +5 -3
  123. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  124. data/sig/rigor/inference.rbs +6 -0
  125. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  126. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  127. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  128. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  129. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  130. data/sig/rigor/plugin/io_boundary.rbs +4 -4
  131. data/sig/rigor/plugin/manifest.rbs +16 -18
  132. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  133. data/sig/rigor/plugin/registry.rbs +1 -1
  134. data/sig/rigor/scope.rbs +31 -50
  135. data/sig/rigor/type.rbs +0 -6
  136. metadata +20 -1
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  require_relative "synthetic_method"
4
6
 
5
7
  module Rigor
@@ -65,8 +67,29 @@ module Rigor
65
67
  { "entries" => entries.map(&:to_h), "class_names" => class_names }
66
68
  end
67
69
 
70
+ # What a dispatch can read from the index, digested (nil for the empty index): each key's rows in their
71
+ # first-wins order, with the return type and the Tier B `origin_module` the dispatcher promotes through,
72
+ # and the synthesised class names. The rest of the provenance (the call site's path and line) is left
73
+ # out, so an edit that only moves the `devise` line keeps the digest. `IncrementalSession` compares it
74
+ # across runs (PR #2286 review M1): the index is built from class-body calls that no declaration
75
+ # signature records, so a recheck that sees it move cannot trust its served files.
76
+ def surface_digest
77
+ return nil if empty?
78
+
79
+ rows = entries.each_with_index
80
+ .sort_by { |entry, index| [entry.kind.to_s, entry.class_name, entry.method_name.to_s, index] }
81
+ .map { |entry, _| surface_row(entry) }
82
+ Digest::SHA256.hexdigest((rows + class_names.sort.map { |name| "class\x1f#{name}" }).join("\x00"))
83
+ end
84
+
68
85
  EMPTY_ROW = [].freeze
69
86
 
87
+ def surface_row(entry)
88
+ [entry.kind, entry.class_name, entry.method_name, entry.return_type,
89
+ entry.provenance[:origin_module], entry.provenance[:plugin_id]].join("\x1f")
90
+ end
91
+ private :surface_row
92
+
70
93
  def bucket(entries, kind)
71
94
  h = {}
72
95
  entries.each do |entry|
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../../source/literals"
5
+
6
+ module Rigor
7
+ module Inference
8
+ module SyntheticMethodScanner
9
+ # The instance-method names a project class answers ahead of a module a trait registry includes into it
10
+ # (PR #2286 review S1). Ruby looks a method up on the class itself before any module it includes, and a
11
+ # module the class itself includes or prepends sits ahead of one `devise` included earlier — so where
12
+ # the project names a member, the Tier B entry for it would answer the call from the wrong rung:
13
+ #
14
+ # class User < ApplicationRecord
15
+ # devise :confirmable
16
+ # include PasswordPolicy # defines password_required?
17
+ # def confirmed? = :yes
18
+ # end
19
+ #
20
+ # `User.new.confirmed?` is `:yes` and `password_required?` is the policy's, not `bool` from the Devise
21
+ # module's RBS. {SyntheticMethodScanner} therefore drops a trait entry whose name this index reports.
22
+ #
23
+ # A name counts as the class's when any reopening of the class body (in any project file) writes `def`,
24
+ # `alias` / `alias_method`, `attr_*`, `define_method` or `delegate` for it, or when a project module the
25
+ # class includes or prepends (followed through that module's own `include` / `prepend`) does. The MRO
26
+ # order of an `include` relative to the `devise` call is NOT consulted: a module included before the
27
+ # call sits behind the Devise module at runtime, but the name is declined anyway. Declining is the
28
+ # conservative direction — the call then reaches the tiers below the synthetic one (the discovered
29
+ # method, the project mixin), which answer it as they did before Tier B was reachable. A mixin constant
30
+ # is matched to a project module by name or by `::`-suffix, again widening only what is declined.
31
+ class ProjectMembers
32
+ # The calls that name members through their literal arguments, and the suffixes each name takes.
33
+ SUFFIXES_BY_CALL = Ractor.make_shareable(
34
+ {
35
+ attr_reader: [""], attr: [""], attr_writer: ["="], attr_accessor: ["", "="], delegate: [""]
36
+ }
37
+ )
38
+ NAME_ARG_CALLS = %i[define_method alias_method].freeze
39
+ MIXIN_CALLS = %i[include prepend].freeze
40
+
41
+ Entry = Struct.new(:names, :mixins)
42
+ private_constant :Entry
43
+
44
+ def initialize(asts)
45
+ @classes = Hash.new { |hash, key| hash[key] = Entry.new(Set.new, []) }
46
+ @modules = Hash.new { |hash, key| hash[key] = Entry.new(Set.new, []) }
47
+ asts.each_value { |ast| walk_namespaces(ast, []) unless ast.nil? }
48
+ @declared = {}
49
+ end
50
+
51
+ # True when `class_name`'s own body, or a project module it includes or prepends, names `method_name`.
52
+ def declares?(class_name, method_name)
53
+ (@declared[class_name] ||= declared_names(class_name)).include?(method_name.to_sym)
54
+ end
55
+
56
+ private
57
+
58
+ def declared_names(class_name)
59
+ entry = @classes.fetch(class_name, nil)
60
+ return Set.new.freeze if entry.nil?
61
+
62
+ names = entry.names.dup
63
+ visited = Set.new
64
+ queue = entry.mixins.dup
65
+ until queue.empty?
66
+ module_keys_for(queue.shift).each do |key|
67
+ next unless visited.add?(key)
68
+
69
+ names.merge(@modules[key].names)
70
+ queue.concat(@modules[key].mixins)
71
+ end
72
+ end
73
+ names.freeze
74
+ end
75
+
76
+ def module_keys_for(name)
77
+ @modules.keys.select { |key| key == name || key.end_with?("::#{name}") }
78
+ end
79
+
80
+ def walk_namespaces(node, scope_stack)
81
+ case node
82
+ when Prism::ClassNode, Prism::ModuleNode
83
+ name = SyntheticMethodScanner.class_name_from(node, scope_stack)
84
+ if name
85
+ table = node.is_a?(Prism::ClassNode) ? @classes : @modules
86
+ collect_members(node.body, table[name])
87
+ end
88
+ inner = scope_stack + [node]
89
+ node.body&.rigor_each_child { |child| walk_namespaces(child, inner) }
90
+ else
91
+ node.rigor_each_child { |child| walk_namespaces(child, scope_stack) }
92
+ end
93
+ end
94
+
95
+ # Every member-naming form under the namespace body, at any depth short of a nested namespace (a
96
+ # `private def`, a `def` under a conditional and an `included do ... def` all count). A nested `class`,
97
+ # `module` or `class << self` is another namespace's body.
98
+ def collect_members(node, entry)
99
+ return if node.nil?
100
+
101
+ case node
102
+ when Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode then return
103
+ when Prism::DefNode then entry.names << node.name if node.receiver.nil?
104
+ when Prism::AliasMethodNode then add_symbol(entry, node.new_name)
105
+ when Prism::CallNode then collect_call(node, entry)
106
+ end
107
+ node.rigor_each_child { |child| collect_members(child, entry) }
108
+ end
109
+
110
+ def collect_call(call, entry)
111
+ return unless call.receiver.nil? || call.receiver.is_a?(Prism::SelfNode)
112
+
113
+ args = call.arguments&.arguments || []
114
+ if MIXIN_CALLS.include?(call.name)
115
+ entry.mixins.concat(args.filter_map { |arg| SyntheticMethodScanner.const_name_string(arg) })
116
+ elsif NAME_ARG_CALLS.include?(call.name)
117
+ add_symbol(entry, args.first)
118
+ elsif (suffixes = SUFFIXES_BY_CALL[call.name])
119
+ args.each { |arg| suffixes.each { |suffix| add_symbol(entry, arg, suffix: suffix) } }
120
+ end
121
+ end
122
+
123
+ def add_symbol(entry, node, suffix: "")
124
+ value = Source::Literals.symbol_or_string_name(node)
125
+ entry.names << :"#{value}#{suffix}" unless value.nil?
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -8,6 +8,7 @@ require_relative "../source/literals"
8
8
  require_relative "../source/node_children"
9
9
  require_relative "synthetic_method"
10
10
  require_relative "synthetic_method_index"
11
+ require_relative "synthetic_method_scanner/project_members"
11
12
 
12
13
  module Rigor
13
14
  module Inference
@@ -64,17 +65,30 @@ module Rigor
64
65
  nested_templates = collect_nested_class_templates(plugin_registry)
65
66
  return SyntheticMethodIndex::EMPTY if templates.empty? && registries.empty? && nested_templates.empty?
66
67
  # Tier B alone cannot emit without an environment: every trait entry — the direct route and the
67
- # concern-re-targeted one — funnels through `module_instance_method_names`, whose first line
68
- # answers `[]` for a nil environment. The production pre-pass passes `environment: nil` (#476),
69
- # so a project whose only contributing plugin registers trait registries (rigor-devise on a
70
- # Rails app) would otherwise pay a whole-project parse to build a provably empty index. This
71
- # gate mirrors that nil guard and MUST be removed in the change that threads a real environment.
68
+ # concern-re-targeted one — funnels through `module_instance_method_names`, whose first line answers `[]`
69
+ # for a nil environment. The production pre-pass hands Tier B a (lazy) environment since #476, so this
70
+ # gate now guards the direct callers that pass none (a spec, a probe): a registry-only scan with
71
+ # no environment would otherwise pay a whole-project parse to build a provably empty index. It mirrors
72
+ # that nil guard and goes with it.
72
73
  return SyntheticMethodIndex::EMPTY if environment.nil? && templates.empty? && nested_templates.empty?
73
74
 
74
- asts = parse_paths(paths, buffer: buffer)
75
+ sources = read_sources(paths, buffer: buffer)
76
+ # With only trait registries contributing, a project that never writes a registry's method name (no
77
+ # `devise` anywhere) cannot produce an entry: a call node named `devise` needs the text `devise` in its
78
+ # file. Skip the parse (the dominant cost) then, and the environment build it would have asked for.
79
+ return SyntheticMethodIndex::EMPTY if templates.empty? && nested_templates.empty? &&
80
+ none_mention_registry_method?(sources, registries)
81
+
82
+ asts = parse_sources(sources)
83
+ entries, class_names = collect_all_entries(asts, templates, registries, nested_templates, environment,
84
+ fact_store)
85
+ SyntheticMethodIndex.new(entries: decline_project_declared(entries, asts), class_names: class_names)
86
+ end
87
+
88
+ # The match + emit walk over every parsed file: `[entries, class_names]`.
89
+ def collect_all_entries(asts, templates, registries, nested_templates, environment, fact_store)
75
90
  hierarchy = build_hierarchy(asts)
76
91
  concern_index = build_concern_index(asts)
77
-
78
92
  entries = []
79
93
  class_names = []
80
94
  asts.each do |path, ast|
@@ -88,8 +102,19 @@ module Rigor
88
102
  end
89
103
  collect_nested_class_entries(entries, class_names, nested_templates, ast, path) unless nested_templates.empty?
90
104
  end
105
+ [entries, class_names]
106
+ end
91
107
 
92
- SyntheticMethodIndex.new(entries: entries, class_names: class_names)
108
+ # PR #2286 review S1 — a Tier B entry whose name the class itself declares, or a project module it includes
109
+ # or prepends declares, is dropped: Ruby answers that call from the project's member, ahead of the module
110
+ # the registry included ({ProjectMembers}). Tier C entries (no `origin_module`) are left as they were.
111
+ def decline_project_declared(entries, asts)
112
+ return entries unless entries.any? { |entry| entry.provenance[:origin_module] }
113
+
114
+ members = ProjectMembers.new(asts)
115
+ entries.reject do |entry|
116
+ entry.provenance[:origin_module] && members.declares?(entry.class_name, entry.method_name)
117
+ end
93
118
  end
94
119
 
95
120
  # Aggregates `(plugin_id, template)` pairs across every plugin's `manifest.heredoc_templates` in registration
@@ -249,16 +274,32 @@ module Rigor
249
274
  call_node.arguments&.arguments&.first
250
275
  end
251
276
 
252
- def parse_paths(paths, buffer: nil)
277
+ # `path => source text` (nil when the file cannot be read), reading an editor buffer's in-flight bytes
278
+ # for the logical path it is bound to.
279
+ def read_sources(paths, buffer: nil)
253
280
  paths.to_h do |path|
254
281
  physical = buffer ? buffer.resolve(path) : path
255
- source = File.read(physical)
256
- [path, Prism.parse(source, filepath: path).value]
282
+ [path, File.read(physical)]
257
283
  rescue StandardError
258
284
  [path, nil]
259
285
  end
260
286
  end
261
287
 
288
+ def parse_sources(sources)
289
+ sources.to_h do |path, source|
290
+ [path, source.nil? ? nil : Prism.parse(source, filepath: path).value]
291
+ rescue StandardError
292
+ [path, nil]
293
+ end
294
+ end
295
+
296
+ def none_mention_registry_method?(sources, registries)
297
+ names = registries.map { |(_plugin_id, registry)| registry.method_name.to_s }
298
+ sources.each_value.none? do |source|
299
+ !source.nil? && names.any? { |name| source.include?(name) }
300
+ end
301
+ end
302
+
262
303
  # ADR-16 slice 4 — Concern re-targeting index.
263
304
  #
264
305
  # Walks every top-level / nested `module M` decl looking for the ActiveSupport::Concern shape:
@@ -534,6 +575,18 @@ module Rigor
534
575
  # Returns the Symbol method-name list defined on `module_name`'s RBS instance definition. Empty Array when the
535
576
  # module is not in the RBS env (silent skip — the synthetic emit produces nothing rather than fabricating method
536
577
  # names).
578
+ #
579
+ # RBS builds a module's instance definition over its self types, or over `Object` when it declares none:
580
+ # `Kernel#puts`, `Object#hash`, the pretty-printer's mixin — and, for `module Devise::Models::X :
581
+ # ActiveRecord::Base`, every `ActiveRecord::Base` method (`save`, `update`) — appear in its method table
582
+ # beside its own members. They are not what including the module adds, and synthesising them would answer
583
+ # the class's own or inherited methods from the module's view of them. A method is kept only when its
584
+ # defining owner is the module itself or one of the module's own ancestors (its `include` / `prepend`
585
+ # chain, which `definition.ancestors` lists without the self types or `Object`).
586
+ #
587
+ # A non-public method is skipped: the tier answers a call by name, and a private module method answered
588
+ # for `user.secret` would silence the call Ruby rejects. A definition that carries no owner (the specs'
589
+ # stubs) keeps every name.
537
590
  def module_instance_method_names(module_name, environment)
538
591
  return [] if environment.nil?
539
592
 
@@ -543,7 +596,14 @@ module Rigor
543
596
  definition = loader.instance_definition(module_name)
544
597
  return [] if definition.nil?
545
598
 
546
- definition.methods.keys
599
+ own_ancestors = nil
600
+ definition.methods.filter_map do |name, method|
601
+ next name unless method.respond_to?(:defined_in)
602
+ next unless method.public?
603
+
604
+ own_ancestors ||= definition.ancestors.ancestors.to_set { |ancestor| ancestor.name.to_s }
605
+ name if own_ancestors.include?(method.defined_in.to_s)
606
+ end
547
607
  rescue StandardError
548
608
  []
549
609
  end
@@ -98,6 +98,12 @@ module Rigor
98
98
  @dirty.key?(uri)
99
99
  end
100
100
 
101
+ # @return true when `uri` is open, its bytes are the ones on disk, and the table holds them faithfully: the
102
+ # buffers a whole-project round analysed as they are.
103
+ def on_disk?(uri)
104
+ open?(uri) && !dirty?(uri) && !desynchronized?(uri)
105
+ end
106
+
101
107
  def [](uri)
102
108
  @entries[uri]
103
109
  end
@@ -100,7 +100,8 @@ module Rigor
100
100
  publish_batch(eligible)
101
101
  end
102
102
 
103
- # Runs one whole-project save round and publishes to the publish set (#246). Called from `didSave`.
103
+ # Runs one whole-project save round and publishes to the publish set (#246), then re-publishes each dirty buffer
104
+ # from its own bytes (ADR-124). Called from `didSave`.
104
105
  #
105
106
  # Analysis scope is the whole project; the PUBLISH SET is the open buffers that are not dirty, plus the
106
107
  # buffer that was just saved. A dirty buffer is excluded because only its own `didChange` analysis has
@@ -117,6 +118,12 @@ module Rigor
117
118
  end
118
119
  end
119
120
 
121
+ # Re-publishes every open buffer from its own bytes, each through {#publish_for}. Called after the project
122
+ # context is invalidated (a watched file or the configuration changed): every marker on screen was computed
123
+ # against the world that is gone, and since ADR-124 a buffer's markers read other files' declarations, so
124
+ # they can move without the buffer itself changing.
125
+ def republish_open = @buffer_table.uris.each { |uri| publish_for(uri) }
126
+
120
127
  # Publishes an EMPTY diagnostic array for `uri`. The LSP-spec idiom for "clear inline markers" — called
121
128
  # from `didClose` so clients drop stale highlights when the user closes a buffer.
122
129
  def publish_empty(uri)
@@ -187,16 +194,20 @@ module Rigor
187
194
 
188
195
  notify(uri, (by_path[path] || []).filter_map { |diagnostic| to_lsp_diagnostic(diagnostic, path) })
189
196
  end
197
+ republish_dirty
190
198
  end
191
199
 
200
+ # The dirty buffers the round may not speak for, re-published from their own bytes (ADR-124). The save that
201
+ # started the round may have moved a declaration a dirty buffer's markers read across files, and nothing
202
+ # else re-analyses that buffer until its next change or save. Each goes through {#publish_for}, so the
203
+ # debouncer and `PublishBatcher` order it against the buffer's own `didChange` publishes: a re-publish run
204
+ # here, beside them, could land its older bytes' markers after a newer edit's.
205
+ def republish_dirty = @buffer_table.uris.each { |uri| publish_for(uri) if @buffer_table.dirty?(uri) }
206
+
192
207
  # The URIs this round may speak for: every open buffer whose bytes are the ones on disk. The just-saved
193
208
  # URI qualifies by definition (the client wrote it), and is named explicitly so a client that sends
194
209
  # `didSave` without a preceding applied `didChange` still refreshes it.
195
- def publish_set(saved_uri)
196
- ([saved_uri] + @buffer_table.uris).uniq.select do |uri|
197
- @buffer_table.open?(uri) && !@buffer_table.dirty?(uri) && !@buffer_table.desynchronized?(uri)
198
- end
199
- end
210
+ def publish_set(saved_uri) = ([saved_uri] + @buffer_table.uris).uniq.select { |uri| @buffer_table.on_disk?(uri) }
200
211
 
201
212
  def run_and_notify(uri, path)
202
213
  entry = @buffer_table[uri]
@@ -239,11 +250,13 @@ module Rigor
239
250
  def publish_batch(jobs)
240
251
  with_tempfiles(jobs) do |bound_jobs|
241
252
  bindings = bound_jobs.map { |job| job.fetch(:binding) }
253
+ @project_context.prepare_discovery_seed
242
254
  dispatcher = Analysis::Runner::BufferPoolDispatcher.new(
243
255
  configuration: @project_context.configuration,
244
256
  cache_store: @project_context.cache_store,
245
257
  environment: @project_context.environment,
246
258
  prebuilt: @project_context.project_scan,
259
+ discovery_seed_for: @project_context.method(:discovery_seed_for),
247
260
  workers: worker_count
248
261
  )
249
262
  diagnostics_per_binding = dispatcher.analyze(bindings)
@@ -282,6 +295,7 @@ module Rigor
282
295
  collect_stats: false,
283
296
  buffer: binding,
284
297
  prebuilt: @project_context.project_scan,
298
+ discovery_seed: @project_context.discovery_seed_for(binding),
285
299
  environment: @project_context.environment
286
300
  )
287
301
  result = runner.run([path])
@@ -7,6 +7,8 @@ require_relative "../analysis/runner"
7
7
  require_relative "../analysis/incremental_session"
8
8
  require_relative "../cache/incremental_snapshot"
9
9
  require_relative "../effects/identity"
10
+ require_relative "../analysis/path_expansion"
11
+ require_relative "../protection/discovery_seed"
10
12
 
11
13
  module Rigor
12
14
  module LanguageServer
@@ -58,6 +60,7 @@ module Rigor
58
60
  @primed_key = nil
59
61
  @buffer_sources = {}
60
62
  @disk_feature_files = nil
63
+ @disk_sig_names = nil
61
64
  @environment_features = nil
62
65
  @generation = 0
63
66
  @environment = nil
@@ -112,6 +115,31 @@ module Rigor
112
115
  @project_scan ||= build_project_scan
113
116
  end
114
117
 
118
+ # Issue #2147 — the cross-file discovery tables a per-buffer publish seeds its `Runner` with
119
+ # (`discovery_seed:`, #260). A `prebuilt:` runner runs no discovery pass of its own, so without them every
120
+ # class another file declares reads as an external ancestor and a publish answers differently from
121
+ # `rigor check` over the same tree. The per-file bundles are walked once per generation; each publish
122
+ # re-folds them with `buffer`'s file re-walked from the buffer's bytes
123
+ # ({Protection::DiscoverySeed.tables_for_buffer}), so the buffer's own declarations win over its on-disk
124
+ # ones, and a file whose bytes on disk moved since the bundles were built is re-walked too (its digest no
125
+ # longer matches).
126
+ #
127
+ # The buffer's file is matched by its real path and handed to the fold under the buffer's own spelling: the
128
+ # binding substitutes bytes only for a path spelled exactly as its logical path (the URI's absolute path),
129
+ # while the configured paths may be relative (`lib`, the default) or reach the file through a symlink.
130
+ def discovery_seed_for(buffer)
131
+ Protection::DiscoverySeed.tables_for_buffer(paths: discovery_paths_for(buffer), bundles: discovery_bundles,
132
+ buffer: buffer, constant_sources: true)
133
+ end
134
+
135
+ # Builds what {#discovery_seed_for} reads once per generation, so a fork batch's children inherit it instead
136
+ # of each walking the project.
137
+ def prepare_discovery_seed
138
+ discovery_bundles
139
+ discovery_real_paths
140
+ nil
141
+ end
142
+
115
143
  # Whole-project diagnostics for one save round (#246). The first call primes an in-process
116
144
  # {Analysis::IncrementalSession} — seeded from the on-disk snapshot a terminal
117
145
  # `rigor check --incremental` may have left, and otherwise from a full baseline; every later call is a
@@ -168,9 +196,14 @@ module Rigor
168
196
  # file.
169
197
  def required_features
170
198
  disk = (@disk_feature_files ||= Environment::RequiredFeatures.feature_files(@configuration))
171
- found = disk.reject { |path, _| @buffer_sources.key?(path) }.values.flatten
172
- found += @buffer_sources.values.flat_map { |source| Environment::RequiredFeatures.features_of(source) }
173
- found.uniq.sort.freeze
199
+ scanned = disk.reject { |path, _| @buffer_sources.key?(path) }.values
200
+ scanned += @buffer_sources.values.map { |source| Environment::RequiredFeatures.scan_source(source) }
201
+ features = scanned.flat_map(&:first).uniq
202
+ names = scanned.flat_map(&:last).to_set
203
+ names.merge(@disk_sig_names ||= Environment::RequiredFeatures.sig_names(
204
+ Environment::RequiredFeatures.configured_sig_paths(@configuration)
205
+ ))
206
+ Environment::RequiredFeatures.relevant(features, names).sort.freeze
174
207
  end
175
208
 
176
209
  # Drops every cached collaborator and bumps the generation. The next reader rebuilds from scratch.
@@ -188,7 +221,11 @@ module Rigor
188
221
  @environment = nil
189
222
  @environment_features = nil
190
223
  @disk_feature_files = nil
224
+ @disk_sig_names = nil
191
225
  @project_scan = nil
226
+ @discovery_paths = nil
227
+ @discovery_real_paths = nil
228
+ @discovery_bundles = nil
192
229
  # The session's per-file cache was computed against the old environment / project scan, so it cannot
193
230
  # outlive them. The next save round primes a fresh one.
194
231
  @incremental_session = nil
@@ -230,6 +267,25 @@ module Rigor
230
267
  )
231
268
  end
232
269
 
270
+ # The configured project files, expanded as `rigor check` expands its configured paths.
271
+ def discovery_paths
272
+ @discovery_paths ||= Analysis::PathExpansion.configured_files(@configuration).freeze
273
+ end
274
+
275
+ # Every entry that reaches the buffer's file is respelled, so a file configured under two roots (`lib` and a
276
+ # symlink to it) is not folded from disk through the second.
277
+ def discovery_paths_for(buffer)
278
+ Protection::DiscoverySeed.respell_for_buffer(discovery_paths, buffer, real_paths: discovery_real_paths)
279
+ end
280
+
281
+ def discovery_real_paths
282
+ @discovery_real_paths ||= discovery_paths.map { |path| Rigor::Analysis::BufferBinding.real_path(path) }.freeze
283
+ end
284
+
285
+ def discovery_bundles
286
+ @discovery_bundles ||= Protection::DiscoverySeed.bundles(paths: discovery_paths)
287
+ end
288
+
233
289
  def build_project_scan
234
290
  runner = Analysis::Runner.new(
235
291
  configuration: @configuration,
@@ -282,11 +282,12 @@ module Rigor
282
282
  end
283
283
 
284
284
  # workspace/didChangeWatchedFiles NOTIFICATION. Invalidates the ProjectContext so cached pre-pass /
285
- # Environment is rebuilt on the next request. Slice 7's floor: any watched file change triggers a full
286
- # context rebuild. Per-file surgical invalidation (per design doc § "Project context refresh") is a
287
- # follow-up; this is the LSP-correct floor.
285
+ # Environment is rebuilt on the next request, and re-publishes the open buffers against the new world
286
+ # (ADR-124). Slice 7's floor: any watched file change triggers a full context rebuild. Per-file surgical
287
+ # invalidation (per design doc § "Project context refresh") is a follow-up; this is the LSP-correct floor.
288
288
  def handle_did_change_watched_files(_params)
289
289
  @project_context&.invalidate!
290
+ @publisher&.republish_open
290
291
  nil
291
292
  end
292
293
 
@@ -295,6 +296,7 @@ module Rigor
295
296
  # (.rigor.yml / Gemfile.lock / etc).
296
297
  def handle_did_change_configuration(_params)
297
298
  @project_context&.invalidate!
299
+ @publisher&.republish_open
298
300
  nil
299
301
  end
300
302
 
@@ -235,7 +235,7 @@ module Rigor
235
235
  end
236
236
 
237
237
  def build_argv(name, args) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity
238
- effective_config = args["config"] || @config_path
238
+ effective_config = present_config(args["config"]) || @config_path
239
239
 
240
240
  case name
241
241
  when "rigor_check"
@@ -290,6 +290,14 @@ module Rigor
290
290
  end
291
291
  end
292
292
 
293
+ # A per-call `config` that is blank names no file, so the session default applies rather than `--config=`
294
+ # reaching the CLI as an empty path (which the CLI refuses).
295
+ def present_config(value)
296
+ return value unless value.is_a?(String) && value.strip.empty?
297
+
298
+ nil
299
+ end
300
+
293
301
  def success(id, result)
294
302
  { "jsonrpc" => "2.0", "id" => id, "result" => result }
295
303
  end
@@ -8,6 +8,7 @@ require "prism"
8
8
  require_relative "manifest"
9
9
  require_relative "node_context"
10
10
  require_relative "../analysis/diagnostic"
11
+ require_relative "../analysis/run_disclosure_credit"
11
12
  require_relative "../cache/engine_source"
12
13
  # `producer generation_cap:` defaults to (and validates against) `Cache::Store::UNBOUNDED_GENERATIONS`, and a
13
14
  # plugin class body can be evaluated before anything else pulled the cache layer in.
@@ -780,11 +781,12 @@ module Rigor
780
781
  # that differs between workers.
781
782
  def disclose_once(key, message:, severity: :info, rule: "load-error")
782
783
  id = key.to_s
783
- return nil if run_registration_taken?(id, batch: false)
784
-
785
- @run_disclosures[id] = {
786
- key: id, message: message.to_s, severity: severity.to_sym, rule: rule.to_s
787
- }.freeze
784
+ unless run_registration_taken?(id, batch: false)
785
+ @run_disclosures[id] = {
786
+ key: id, message: message.to_s, severity: severity.to_sym, rule: rule.to_s
787
+ }.freeze
788
+ end
789
+ attribute_run_registration(id)
788
790
  nil
789
791
  end
790
792
 
@@ -816,14 +818,15 @@ module Rigor
816
818
  # raises `ArgumentError`, which the engine reports as this plugin's `runtime-error`.
817
819
  def emit_once(key, diagnostics)
818
820
  id = key.to_s
819
- return nil if run_registration_taken?(id, batch: true)
820
-
821
- batch = Array(diagnostics).map do |row|
822
- next row.dup.freeze if row.is_a?(Rigor::Analysis::Diagnostic)
821
+ unless run_registration_taken?(id, batch: true)
822
+ batch = Array(diagnostics).map do |row|
823
+ next row.dup.freeze if row.is_a?(Rigor::Analysis::Diagnostic)
823
824
 
824
- raise ArgumentError, "emit_once expects Rigor::Analysis::Diagnostic rows, got #{row.class}"
825
+ raise ArgumentError, "emit_once expects Rigor::Analysis::Diagnostic rows, got #{row.class}"
826
+ end
827
+ @run_disclosures[id] = { key: id, diagnostics: batch.freeze }.freeze
825
828
  end
826
- @run_disclosures[id] = { key: id, diagnostics: batch.freeze }.freeze
829
+ attribute_run_registration(id)
827
830
  nil
828
831
  end
829
832
 
@@ -847,6 +850,18 @@ module Rigor
847
850
  end
848
851
  private :run_registration_taken?
849
852
 
853
+ # Issue #2020 — hands every call, a repeat of a key already registered included, to the engine's per-file
854
+ # attribution ({Analysis::RunDisclosureCredit}), so an `--incremental` recheck can replay the row for a file
855
+ # it serves from cache. A repeat counts because a discloser memoises the input its notice is about and
856
+ # re-registers the notice from each file's hook. A batch registered empty adds nothing to replay.
857
+ def attribute_run_registration(id)
858
+ record = @run_disclosures[id]
859
+ return if record.nil? || record[:diagnostics]&.empty?
860
+
861
+ Analysis::RunDisclosureCredit.attribute(self, record)
862
+ end
863
+ private :attribute_run_registration
864
+
850
865
  # Boilerplate-reduction helper (review §1.3): the "did you mean …?" suggestion every
851
866
  # diagnostic-emitting plugin otherwise hand-rolls. Returns the closest of `candidates` to `name` via
852
867
  # `DidYouMean::SpellChecker` (the same engine Ruby's own `NoMethodError` hints use), or `nil` when