rigortype 0.1.13 → 0.1.15

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.
@@ -109,8 +109,10 @@ module Rigor
109
109
  @cached_plugin_prepare_diagnostics = [].freeze
110
110
  @project_discovered_classes = {}.freeze
111
111
  @project_discovered_def_nodes = {}.freeze
112
+ @project_discovered_def_sources = {}.freeze
112
113
  @project_discovered_superclasses = {}.freeze
113
114
  @project_discovered_includes = {}.freeze
115
+ @project_discovered_method_visibilities = {}.freeze
114
116
  end
115
117
 
116
118
  # ADR-pending editor mode — present when the runner is wired
@@ -257,8 +259,10 @@ module Rigor
257
259
  def_index =
258
260
  Inference::ScopeIndexer.discovered_def_index_for_paths(expansion.fetch(:files), buffer: @buffer)
259
261
  @project_discovered_def_nodes = def_index.fetch(:def_nodes)
262
+ @project_discovered_def_sources = def_index.fetch(:def_sources)
260
263
  @project_discovered_superclasses = def_index.fetch(:superclasses)
261
264
  @project_discovered_includes = def_index.fetch(:includes)
265
+ @project_discovered_method_visibilities = def_index.fetch(:method_visibilities)
262
266
  end
263
267
 
264
268
  # Internal: adopts a frozen {ProjectScan} snapshot supplied
@@ -1449,10 +1453,16 @@ module Rigor
1449
1453
  unless @project_discovered_def_nodes.empty?
1450
1454
  scope = scope.with_discovered_def_nodes(@project_discovered_def_nodes)
1451
1455
  end
1456
+ unless @project_discovered_def_sources.empty?
1457
+ scope = scope.with_discovered_def_sources(@project_discovered_def_sources)
1458
+ end
1452
1459
  unless @project_discovered_superclasses.empty?
1453
1460
  scope = scope.with_discovered_superclasses(@project_discovered_superclasses)
1454
1461
  end
1455
1462
  scope = scope.with_discovered_includes(@project_discovered_includes) unless @project_discovered_includes.empty?
1463
+ unless @project_discovered_method_visibilities.empty?
1464
+ scope = scope.with_discovered_method_visibilities(@project_discovered_method_visibilities)
1465
+ end
1456
1466
  scope
1457
1467
  end
1458
1468
 
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class CLI
5
+ # `rigor plugin` (singular) — discover and read the plugin source
6
+ # bundled with the `rigortype` gem.
7
+ #
8
+ # Rigor ships ~30 production plugins under `plugins/` and a set of
9
+ # tutorial plugins under `examples/`. When Rigor is installed via
10
+ # `mise` / `gem install` the gem checkout is on disk, so a plugin
11
+ # author (or an AI coding agent following the `rigor-plugin-author`
12
+ # skill) can read a real, working plugin as a worked example —
13
+ # instead of guessing the `Rigor::Plugin::Base` surface from prose.
14
+ # This command outputs the absolute paths so they can be found and
15
+ # read regardless of where the gem landed.
16
+ #
17
+ # It is deliberately distinct from `rigor plugins` (plural), which
18
+ # reports the activation status of the plugins configured in *your*
19
+ # `.rigor.yml`. This command (singular) browses the plugins bundled
20
+ # in the *toolchain*. Mnemonic: "plugins" = my config; "plugin" =
21
+ # the catalogue I can learn from.
22
+ #
23
+ # Subcommands:
24
+ #
25
+ # - `rigor plugin list` — every bundled plugin + example,
26
+ # name + absolute directory path.
27
+ # - `rigor plugin path <name>` — one-line absolute path to the
28
+ # plugin's directory (Read-tool input).
29
+ # - `rigor plugin print <name>` — a header (dir / lib / sig / README
30
+ # paths) followed by the plugin's main
31
+ # `lib/<name>.rb` source body.
32
+ # - `rigor plugin root` — the rigortype gem root and its key
33
+ # subdirectories (lib/, plugins/,
34
+ # examples/, skills/, sig/), so an
35
+ # author can read the public plugin
36
+ # API (`lib/rigor/plugin.rb`) directly.
37
+ #
38
+ # `rigor plugin` with no subcommand is an alias for `list`.
39
+ #
40
+ # **Docker / cross-filesystem note.** Every path printed is resolved
41
+ # at runtime from this file's location, so it is correct *on the
42
+ # filesystem where `rigor` runs*. If you run `rigor` inside a
43
+ # container but read files from the host (or vice versa), the paths
44
+ # will not resolve — read them from the same environment that ran
45
+ # the command (`rigor plugin print` inlines the body for exactly
46
+ # this case: it works with no file-reading tool at all).
47
+ class PluginCommand
48
+ USAGE = <<~USAGE
49
+ Usage: rigor plugin <subcommand> [args]
50
+
51
+ Browse the plugins bundled in the rigortype toolchain (worked
52
+ examples for authoring your own). For the activation status of
53
+ the plugins in your .rigor.yml, use `rigor plugins` (plural).
54
+
55
+ Subcommands:
56
+ list List bundled + example plugins (default)
57
+ path <name> Print the absolute directory path of <name>
58
+ print <name> Print <name>'s main lib source, with a header
59
+ root Print the gem root + key subdirectories
60
+
61
+ Examples:
62
+ rigor plugin list
63
+ rigor plugin path rigor-activerecord
64
+ rigor plugin print rigor-activesupport-core-ext
65
+ rigor plugin root
66
+ USAGE
67
+
68
+ # The bundled plugins/examples/source live at `<gem_root>/...`.
69
+ # From `lib/rigor/cli/plugin_command.rb` the gem root is three
70
+ # directories up (matching SkillCommand::SKILLS_ROOT).
71
+ GEM_ROOT = File.expand_path("../../..", __dir__)
72
+ PLUGINS_ROOT = File.join(GEM_ROOT, "plugins")
73
+ EXAMPLES_ROOT = File.join(GEM_ROOT, "examples")
74
+
75
+ def initialize(argv:, out: $stdout, err: $stderr)
76
+ @argv = argv
77
+ @out = out
78
+ @err = err
79
+ end
80
+
81
+ # @return [Integer] CLI exit status.
82
+ def run
83
+ subcommand = @argv.shift || "list"
84
+
85
+ case subcommand
86
+ when "list" then run_list
87
+ when "path" then run_path
88
+ when "print" then run_print
89
+ when "root" then run_root
90
+ when "-h", "--help", "help"
91
+ @out.puts(USAGE)
92
+ 0
93
+ else
94
+ @err.puts("Unknown subcommand: #{subcommand}")
95
+ @err.puts(USAGE)
96
+ Rigor::CLI::EXIT_USAGE
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ def run_list
103
+ plugins = discover(PLUGINS_ROOT)
104
+ examples = discover(EXAMPLES_ROOT)
105
+ if plugins.empty? && examples.empty?
106
+ @err.puts("No bundled plugins found under #{PLUGINS_ROOT}")
107
+ return 1
108
+ end
109
+
110
+ width = (plugins + examples).map { |p| p.fetch(:name).length }.max
111
+ emit_group("Production plugins", PLUGINS_ROOT, plugins, width)
112
+ emit_group("Example plugins (tutorials)", EXAMPLES_ROOT, examples, width)
113
+ @out.puts
114
+ @out.puts("Engine source root: #{GEM_ROOT}")
115
+ @out.puts(" public plugin API: #{File.join(GEM_ROOT, 'lib/rigor/plugin.rb')}")
116
+ @out.puts(docker_note)
117
+ 0
118
+ end
119
+
120
+ def emit_group(label, root, entries, width)
121
+ return if entries.empty?
122
+
123
+ @out.puts("#{label} — under #{root}:")
124
+ entries.each do |entry|
125
+ @out.puts(format(" %-#{width}s %s", entry.fetch(:name), entry.fetch(:path)))
126
+ end
127
+ end
128
+
129
+ def run_path
130
+ name = @argv.shift
131
+ return usage_error("`path` requires a plugin name") if name.nil?
132
+
133
+ plugin = find(name)
134
+ return name_error(name) if plugin.nil?
135
+
136
+ @out.puts(plugin.fetch(:path))
137
+ 0
138
+ end
139
+
140
+ def run_print
141
+ name = @argv.shift
142
+ return usage_error("`print` requires a plugin name") if name.nil?
143
+
144
+ plugin = find(name)
145
+ return name_error(name) if plugin.nil?
146
+
147
+ @out.puts(render_print_header(plugin))
148
+ @out.puts
149
+ entry = main_source_file(plugin)
150
+ if entry
151
+ @out.write(File.read(entry))
152
+ else
153
+ @out.puts("# (no lib/#{plugin.fetch(:name)}.rb entry file found; browse the directory above)")
154
+ end
155
+ 0
156
+ end
157
+
158
+ def run_root
159
+ @out.puts("rigortype gem root: #{GEM_ROOT}")
160
+ {
161
+ "lib (engine source)" => File.join(GEM_ROOT, "lib"),
162
+ "lib/rigor/plugin.rb (public plugin API)" => File.join(GEM_ROOT, "lib/rigor/plugin.rb"),
163
+ "plugins (production plugins)" => PLUGINS_ROOT,
164
+ "examples (tutorial plugins)" => EXAMPLES_ROOT,
165
+ "skills (Agent Skills)" => File.join(GEM_ROOT, "skills"),
166
+ "sig (bundled RBS)" => File.join(GEM_ROOT, "sig")
167
+ }.each do |label, path|
168
+ marker = File.exist?(path) ? "" : " (missing)"
169
+ @out.puts(format(" %<label>-42s %<path>s%<marker>s", label: label, path: path, marker: marker))
170
+ end
171
+ @out.puts(docker_note)
172
+ 0
173
+ end
174
+
175
+ # The header that precedes the plugin body when an author runs
176
+ # `rigor plugin print <name>`. `# `-prefixed so the combined
177
+ # output stays readable; the Ruby body below it is unchanged.
178
+ def render_print_header(plugin)
179
+ dir = plugin.fetch(:path)
180
+ sig = File.join(dir, "sig")
181
+ readme = File.join(dir, "README.md")
182
+ <<~HEADER.chomp
183
+ # Rigor plugin: #{plugin.fetch(:name)} (#{plugin.fetch(:kind)})
184
+ # Directory: #{dir}
185
+ # Lib: #{File.join(dir, 'lib')}
186
+ # Sig: #{File.directory?(sig) ? sig : '(none)'}
187
+ # README: #{File.file?(readme) ? readme : '(none)'}
188
+ #
189
+ # A real, working plugin shipped with rigortype #{Rigor::VERSION}.
190
+ # The main source body is below; read the other files from the
191
+ # paths above. To suppress `call.undefined-method` for methods a
192
+ # DSL generates, study how an RBS-bundle plugin ships `sig/` (see
193
+ # `rigor plugin print rigor-activesupport-core-ext`).
194
+ HEADER
195
+ end
196
+
197
+ def discover(root)
198
+ return [] unless File.directory?(root)
199
+
200
+ kind = root == EXAMPLES_ROOT ? "example" : "production"
201
+ Dir.children(root).sort.filter_map do |name|
202
+ dir = File.join(root, name)
203
+ next unless File.directory?(dir)
204
+
205
+ { name: name, path: dir, kind: kind }
206
+ end
207
+ end
208
+
209
+ # Match the directory name with or without the conventional
210
+ # `rigor-` prefix, so both `rigor-activerecord` and
211
+ # `activerecord` resolve.
212
+ def find(name)
213
+ all = discover(PLUGINS_ROOT) + discover(EXAMPLES_ROOT)
214
+ all.find { |p| p.fetch(:name) == name } ||
215
+ all.find { |p| p.fetch(:name).delete_prefix("rigor-") == name.delete_prefix("rigor-") }
216
+ end
217
+
218
+ def main_source_file(plugin)
219
+ dir = plugin.fetch(:path)
220
+ candidate = File.join(dir, "lib", "#{plugin.fetch(:name)}.rb")
221
+ return candidate if File.file?(candidate)
222
+
223
+ Dir.glob(File.join(dir, "lib", "*.rb")).min
224
+ end
225
+
226
+ def docker_note
227
+ "\nNote: paths are local to where `rigor` runs. If you run rigor in a " \
228
+ "container,\nread these files from the same filesystem (use " \
229
+ "`rigor plugin print` to inline a body)."
230
+ end
231
+
232
+ def name_error(name)
233
+ @err.puts("Unknown plugin: #{name}")
234
+ @err.puts("Run `rigor plugin list` to see the bundled plugins.")
235
+ 1
236
+ end
237
+
238
+ def usage_error(message)
239
+ @err.puts(message)
240
+ @err.puts(USAGE)
241
+ Rigor::CLI::EXIT_USAGE
242
+ end
243
+ end
244
+ end
245
+ end
data/lib/rigor/cli.rb CHANGED
@@ -33,6 +33,7 @@ module Rigor
33
33
  "triage" => :run_triage,
34
34
  "coverage" => :run_coverage,
35
35
  "plugins" => :run_plugins,
36
+ "plugin" => :run_plugin,
36
37
  "playground" => :run_playground,
37
38
  "skill" => :run_skill
38
39
  }.freeze
@@ -642,6 +643,12 @@ module Rigor
642
643
  CLI::SkillCommand.new(argv: @argv, out: @out, err: @err).run
643
644
  end
644
645
 
646
+ def run_plugin
647
+ require_relative "cli/plugin_command"
648
+
649
+ CLI::PluginCommand.new(argv: @argv, out: @out, err: @err).run
650
+ end
651
+
645
652
  def write_result(result, format)
646
653
  case format
647
654
  when "json"
@@ -688,6 +695,7 @@ module Rigor
688
695
  triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
689
696
  coverage Report type-precision coverage (precise vs Dynamic ratio)
690
697
  plugins Report activation status of every configured plugin
698
+ plugin Browse bundled plugin source as worked examples (list/path/print/root)
691
699
  playground Start the browser playground (requires rigor-playground gem)
692
700
  skill List or print bundled Agent Skills (rigor-project-init, ...)
693
701
  version Print the Rigor version
@@ -51,6 +51,9 @@ module Rigor
51
51
  "dump.type" => :info,
52
52
  "def.return-type-mismatch" => :warning,
53
53
  "def.method-visibility-mismatch" => :warning,
54
+ "def.override-visibility-reduced" => :off,
55
+ "def.override-return-widened" => :off,
56
+ "def.override-param-narrowed" => :off,
54
57
  "def.ivar-write-mismatch" => :warning
55
58
  }.freeze,
56
59
  balanced: {
@@ -67,6 +70,9 @@ module Rigor
67
70
  "dump.type" => :info,
68
71
  "def.return-type-mismatch" => :warning,
69
72
  "def.method-visibility-mismatch" => :error,
73
+ "def.override-visibility-reduced" => :warning,
74
+ "def.override-return-widened" => :warning,
75
+ "def.override-param-narrowed" => :warning,
70
76
  "def.ivar-write-mismatch" => :warning
71
77
  }.freeze,
72
78
  strict: {
@@ -83,6 +89,9 @@ module Rigor
83
89
  "dump.type" => :error,
84
90
  "def.return-type-mismatch" => :error,
85
91
  "def.method-visibility-mismatch" => :error,
92
+ "def.override-visibility-reduced" => :error,
93
+ "def.override-return-widened" => :error,
94
+ "def.override-param-narrowed" => :error,
86
95
  "def.ivar-write-mismatch" => :error
87
96
  }.freeze
88
97
  }.freeze
@@ -34,6 +34,20 @@ module Rigor
34
34
  # `sig/`), and `local` (a user-managed RBS dir) — all
35
35
  # produce a directory under the collection root and are
36
36
  # admitted.
37
+ #
38
+ # The `source.type` filter alone is NOT sufficient: gems
39
+ # that were extracted from Ruby's stdlib into standalone
40
+ # default gems (e.g. `cgi`, `logger`, `base64`, `csv`,
41
+ # `bigdecimal`) are published in `ruby/gem_rbs_collection`
42
+ # under a `git` source type, yet rigor ALSO loads them
43
+ # from its bundled stdlib via `DEFAULT_LIBRARIES`. Loading
44
+ # both copies triggers the very
45
+ # `RBS::DuplicatedDeclarationError` this module exists to
46
+ # avoid (observed on a Rails 8 app: `.gem_rbs_collection/
47
+ # cgi/0.5/` vs the bundled `stdlib/cgi`). The
48
+ # `skip_gem_names:` parameter lets the caller pass the set
49
+ # of library names rigor already loads so those gems are
50
+ # dropped regardless of `source.type`.
37
51
  SKIPPED_SOURCE_TYPES = Set["stdlib"].freeze
38
52
 
39
53
  DEFAULT_COLLECTION_PATH = ".gem_rbs_collection"
@@ -47,14 +61,21 @@ module Rigor
47
61
  # @param auto_detect [Boolean] when true and
48
62
  # `lockfile_path:` is nil, look for
49
63
  # `<project_root>/rbs_collection.lock.yaml`.
64
+ # @param skip_gem_names [Array<String>, Set<String>] gem
65
+ # names rigor already loads from its bundled stdlib (the
66
+ # merged `DEFAULT_LIBRARIES + libraries:` set). Entries
67
+ # whose `name` is in this set are dropped regardless of
68
+ # `source.type` to avoid `RBS::DuplicatedDeclarationError`
69
+ # on stdlib-extracted default gems. Defaults to empty.
50
70
  # @return [Array<Pathname>] every
51
71
  # `<collection_path>/<gem-name>/<gem-version>/`
52
72
  # directory listed in the lockfile whose entry has a
53
- # non-skipped source type and whose directory exists on
54
- # disk. Returns `[]` when no lockfile is resolvable,
55
- # when the YAML is unreadable, or when the collection
56
- # path doesn't exist.
57
- def self.discover(lockfile_path:, project_root: Dir.pwd, auto_detect: true)
73
+ # non-skipped source type, whose `name` is not in
74
+ # `skip_gem_names:`, and whose directory exists on disk.
75
+ # Returns `[]` when no lockfile is resolvable, when the
76
+ # YAML is unreadable, or when the collection path
77
+ # doesn't exist.
78
+ def self.discover(lockfile_path:, project_root: Dir.pwd, auto_detect: true, skip_gem_names: [])
58
79
  resolved = resolve_lockfile_path(
59
80
  lockfile_path: lockfile_path,
60
81
  project_root: project_root,
@@ -68,7 +89,7 @@ module Rigor
68
89
  collection_root = resolve_collection_root(resolved, data)
69
90
  return [] unless collection_root.directory?
70
91
 
71
- gem_paths_from(collection_root, data)
92
+ gem_paths_from(collection_root, data, skip_gem_names.to_set)
72
93
  end
73
94
 
74
95
  # Returns the resolved lockfile path (`Pathname`) or `nil`
@@ -105,7 +126,7 @@ module Rigor
105
126
  end
106
127
  private_class_method :resolve_collection_root
107
128
 
108
- def self.gem_paths_from(collection_root, data)
129
+ def self.gem_paths_from(collection_root, data, skip_gem_names)
109
130
  Array(data["gems"]).filter_map do |entry|
110
131
  next unless entry.is_a?(Hash)
111
132
 
@@ -115,6 +136,7 @@ module Rigor
115
136
  name = entry["name"]
116
137
  version = entry["version"]
117
138
  next if name.nil? || version.nil?
139
+ next if skip_gem_names.include?(name.to_s)
118
140
 
119
141
  gem_root = collection_root + name.to_s + version.to_s
120
142
  gem_root if gem_root.directory?
@@ -122,6 +122,23 @@ module Rigor
122
122
  Pathname(File.join(VENDORED_GEM_SIGS_ROOT, gem_dir))
123
123
  end
124
124
  end
125
+
126
+ # Gem names whose RBS ships under
127
+ # `data/vendored_gem_sigs/<gem>/`. The directory walk is
128
+ # the source of truth (the `README.md` sibling is not a
129
+ # gem and is excluded). Callers building the RBS env use
130
+ # this set to drop the matching `rbs collection install`
131
+ # directory before it double-declares against the
132
+ # vendored copy — the same hazard `DEFAULT_LIBRARIES`
133
+ # creates for stdlib-extracted gems. See
134
+ # `RbsCollectionDiscovery`'s `skip_gem_names:`.
135
+ def vendored_gem_names
136
+ return [] unless File.directory?(VENDORED_GEM_SIGS_ROOT)
137
+
138
+ Dir.children(VENDORED_GEM_SIGS_ROOT).reject do |child|
139
+ File.file?(File.join(VENDORED_GEM_SIGS_ROOT, child))
140
+ end
141
+ end
125
142
  end
126
143
 
127
144
  attr_reader :libraries, :signature_paths, :cache_store, :virtual_rbs
@@ -263,11 +263,22 @@ module Rigor
263
263
  # resulting `rbs_collection.lock.yaml` and feed each
264
264
  # gem's `<collection_path>/<name>/<version>/` directory
265
265
  # into `signature_paths:`. Stdlib-typed entries are
266
- # skipped (already covered by `DEFAULT_LIBRARIES`).
266
+ # skipped (already covered by `DEFAULT_LIBRARIES`), as
267
+ # are gems whose RBS rigor already loads from another
268
+ # source — stdlib-extracted default gems (`cgi`,
269
+ # `logger`, …, shipped by the collection under a `git`
270
+ # source) and the `data/vendored_gem_sigs/` bundle
271
+ # (`redis`, `nokogiri`, `pg`, …). `skip_gem_names:`
272
+ # passes both sets so the collection copy doesn't
273
+ # double-declare against rigor's bundled RBS (the
274
+ # `RBS::DuplicatedDeclarationError` hazard).
275
+ merged_libraries = (DEFAULT_LIBRARIES + libraries.map(&:to_s)).uniq
276
+ skip_gem_names = merged_libraries + RbsLoader.vendored_gem_names
267
277
  collection_paths = RbsCollectionDiscovery.discover(
268
278
  lockfile_path: rbs_collection_lockfile,
269
279
  project_root: root,
270
- auto_detect: rbs_collection_auto_detect
280
+ auto_detect: rbs_collection_auto_detect,
281
+ skip_gem_names: skip_gem_names
271
282
  ).map(&:to_s)
272
283
  # ADR-25 — RBS signature directories contributed by loaded
273
284
  # plugins via their manifest `signature_paths:`. Resolved
@@ -278,7 +289,6 @@ module Rigor
278
289
  # degrades through the same O7 failure-memo path.
279
290
  plugin_sig_paths = plugin_registry ? plugin_registry.signature_paths.map(&:to_s) : []
280
291
  loader_signature_paths = resolved_paths + plugin_sig_paths + gem_sig_paths + collection_paths
281
- merged_libraries = (DEFAULT_LIBRARIES + libraries.map(&:to_s)).uniq
282
292
  # ADR-32 WD4 + WD5 — invoke each loaded plugin's
283
293
  # `source_rbs_synthesizer` once per project source file
284
294
  # and collect non-nil `[filename, rbs_source]` pairs.
@@ -114,15 +114,14 @@ module Rigor
114
114
  # def nodes, the class -> superclass map, and the
115
115
  # class/module -> included-modules map, each merged under
116
116
  # the cross-file pre-pass seed (see below).
117
- seeded_scope = merge_project_method_indexes(seeded_scope, default_scope, root)
118
-
119
117
  # v0.1.2 — per-class table of method visibilities
120
118
  # (`:public` / `:private` / `:protected`). The
121
- # `def.method-visibility-mismatch` CheckRule consults
122
- # the table to flag explicit-non-self calls to a
123
- # private user method.
124
- discovered_method_visibilities = build_discovered_method_visibilities(root)
125
- seeded_scope = seeded_scope.with_discovered_method_visibilities(discovered_method_visibilities)
119
+ # `def.method-visibility-mismatch` and ADR-35
120
+ # `def.override-visibility-reduced` CheckRules consult the
121
+ # table. Seeded inside `merge_project_method_indexes` so the
122
+ # per-file visibilities merge OVER the cross-file project seed
123
+ # rather than overwriting it.
124
+ seeded_scope = merge_project_method_indexes(seeded_scope, default_scope, root)
126
125
 
127
126
  table = {}.compare_by_identity
128
127
  table.default = seeded_scope
@@ -161,11 +160,18 @@ module Rigor
161
160
  includes = default_scope.discovered_includes.merge(
162
161
  build_discovered_includes(root)
163
162
  ) { |_class, cross_file, per_file| (cross_file + per_file).uniq }
163
+ # ADR-35 — per-file visibilities merged OVER the cross-file
164
+ # seed (the current file is authoritative for its own classes;
165
+ # sibling-file ancestors are preserved from the project seed).
166
+ method_visibilities = default_scope.discovered_method_visibilities.merge(
167
+ build_discovered_method_visibilities(root)
168
+ ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
164
169
 
165
170
  seeded_scope
166
171
  .with_discovered_def_nodes(def_nodes)
167
172
  .with_discovered_superclasses(superclasses)
168
173
  .with_discovered_includes(includes)
174
+ .with_discovered_method_visibilities(method_visibilities)
169
175
  end
170
176
 
171
177
  # Slice 7 phase 2. Builds the class-level ivar accumulator
@@ -1365,31 +1371,63 @@ module Rigor
1365
1371
  # (`Mastodon::CLI::Accounts` calling a helper defined in
1366
1372
  # `Mastodon::CLI::Base`).
1367
1373
  #
1374
+ # The returned `def_sources` map mirrors `def_nodes` but stores
1375
+ # a `"path:line"` String per `(class_name, method_name)` instead
1376
+ # of the `Prism::DefNode`. A `Prism::Location` does not expose
1377
+ # its source file through public API, so the source site is
1378
+ # captured here, in the pre-pass loop that still holds `path`.
1379
+ # `CheckRules#undefined_method_diagnostic` consults the seeded
1380
+ # copy to name the defining file when a project monkey-patch on
1381
+ # a core/stdlib/gem class is called cross-file (ADR-17). First
1382
+ # write wins, matching `def_nodes`' own merge order.
1383
+ #
1368
1384
  # @param paths [Array<String>] project file paths.
1369
1385
  # @param buffer [Rigor::Analysis::BufferBinding, nil]
1370
- # @return [Hash{Symbol => Hash}] `{ def_nodes:, superclasses: }`
1386
+ # @return [Hash{Symbol => Hash}]
1387
+ # `{ def_nodes:, def_sources:, superclasses:, includes: }`
1371
1388
  def discovered_def_index_for_paths(paths, buffer: nil)
1372
- def_nodes = {}
1373
- superclasses = {}
1374
- includes = {}
1389
+ acc = { def_nodes: {}, def_sources: {}, superclasses: {}, includes: {}, method_visibilities: {} }
1375
1390
  paths.each do |path|
1376
1391
  physical = buffer ? buffer.resolve(path) : path
1377
1392
  root = Prism.parse(File.read(physical), filepath: path).value
1378
- build_discovered_def_nodes(root).each do |class_name, methods|
1379
- (def_nodes[class_name] ||= {}).merge!(methods)
1380
- end
1381
- superclasses.merge!(build_discovered_superclasses(root))
1382
- build_discovered_includes(root).each do |class_name, mods|
1383
- includes[class_name] = ((includes[class_name] || []) + mods).uniq
1384
- end
1393
+ accumulate_project_index(acc, path, root)
1385
1394
  rescue StandardError
1386
1395
  # Skip files that fail to parse or read; the per-file
1387
1396
  # analyzer surfaces the parse error separately.
1388
1397
  next
1389
1398
  end
1390
- def_nodes.each_value(&:freeze)
1391
- includes.each_value(&:freeze)
1392
- { def_nodes: def_nodes.freeze, superclasses: superclasses.freeze, includes: includes.freeze }
1399
+ %i[def_nodes def_sources includes method_visibilities].each { |key| acc[key].each_value(&:freeze) }
1400
+ acc.transform_values(&:freeze)
1401
+ end
1402
+
1403
+ # Folds one file's class-keyed indexes into the cross-file
1404
+ # accumulator. `method_visibilities` (ADR-35) is collected here so
1405
+ # the override-visibility-reduced rule can read an ancestor's
1406
+ # visibility declared in a sibling file.
1407
+ def accumulate_project_index(acc, path, root)
1408
+ merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, root)
1409
+ acc[:superclasses].merge!(build_discovered_superclasses(root))
1410
+ build_discovered_includes(root).each do |class_name, mods|
1411
+ acc[:includes][class_name] = ((acc[:includes][class_name] || []) + mods).uniq
1412
+ end
1413
+ build_discovered_method_visibilities(root).each do |class_name, table|
1414
+ (acc[:method_visibilities][class_name] ||= {}).merge!(table)
1415
+ end
1416
+ end
1417
+
1418
+ # Merges one file's `class → method → DefNode` map into the
1419
+ # cross-file `def_nodes` index and records each method's first-
1420
+ # seen `"path:line"` definition site in `def_sources` (ADR-17 —
1421
+ # the un-registered-project-patch signal `call.undefined-method`
1422
+ # and `rigor triage` key on).
1423
+ def merge_discovered_defs(def_nodes, def_sources, path, root)
1424
+ build_discovered_def_nodes(root).each do |class_name, methods|
1425
+ (def_nodes[class_name] ||= {}).merge!(methods)
1426
+ sources = (def_sources[class_name] ||= {})
1427
+ methods.each do |method_name, def_node|
1428
+ sources[method_name] ||= "#{path}:#{def_node.location&.start_line || 1}"
1429
+ end
1430
+ end
1393
1431
  end
1394
1432
 
1395
1433
  # Class-only variant of `record_declarations` — descends
data/lib/rigor/scope.rb CHANGED
@@ -20,7 +20,7 @@ module Rigor
20
20
  :ivars, :cvars, :globals,
21
21
  :class_ivars, :class_cvars, :program_globals,
22
22
  :discovered_classes, :in_source_constants, :discovered_methods,
23
- :discovered_def_nodes, :discovered_method_visibilities,
23
+ :discovered_def_nodes, :discovered_def_sources, :discovered_method_visibilities,
24
24
  :discovered_superclasses, :discovered_includes,
25
25
  :indexed_narrowings, :method_chain_narrowings,
26
26
  :source_path
@@ -89,6 +89,7 @@ module Rigor
89
89
  in_source_constants: EMPTY_VAR_BINDINGS,
90
90
  discovered_methods: EMPTY_CLASS_BINDINGS,
91
91
  discovered_def_nodes: EMPTY_CLASS_BINDINGS,
92
+ discovered_def_sources: EMPTY_CLASS_BINDINGS,
92
93
  discovered_method_visibilities: EMPTY_CLASS_BINDINGS,
93
94
  discovered_superclasses: EMPTY_CLASS_BINDINGS,
94
95
  discovered_includes: EMPTY_CLASS_BINDINGS,
@@ -111,6 +112,7 @@ module Rigor
111
112
  @in_source_constants = in_source_constants
112
113
  @discovered_methods = discovered_methods
113
114
  @discovered_def_nodes = discovered_def_nodes
115
+ @discovered_def_sources = discovered_def_sources
114
116
  @discovered_method_visibilities = discovered_method_visibilities
115
117
  @discovered_superclasses = discovered_superclasses
116
118
  @discovered_includes = discovered_includes
@@ -361,6 +363,27 @@ module Rigor
361
363
  rebuild(discovered_def_nodes: table)
362
364
  end
363
365
 
366
+ # Companion to {#user_def_for}: returns the `"path:line"` where
367
+ # the project defines `class_name#method_name` (instance-side),
368
+ # or nil. Populated only by the cross-file project pre-pass
369
+ # ({Inference::ScopeIndexer.discovered_def_index_for_paths}) — a
370
+ # `Prism::Location` hides its source file, so the site is recorded
371
+ # at scan time. `CheckRules#undefined_method_diagnostic` consults
372
+ # this to name the defining file when a project monkey-patch on a
373
+ # core/stdlib/gem class is called cross-file, so the diagnostic
374
+ # can point at `pre_eval:` (ADR-17) instead of reading as a bare
375
+ # unresolved call.
376
+ def user_def_site_for(class_name, method_name)
377
+ table = @discovered_def_sources[class_name.to_s]
378
+ return nil unless table
379
+
380
+ table[method_name.to_sym]
381
+ end
382
+
383
+ def with_discovered_def_sources(table)
384
+ rebuild(discovered_def_sources: table)
385
+ end
386
+
364
387
  # ADR-24 slice 2 — per-class table mapping a fully
365
388
  # qualified user-class name to its superclass name AS
366
389
  # WRITTEN at the `class Foo < Bar` declaration (`"Bar"`,
@@ -558,6 +581,7 @@ module Rigor
558
581
  class_ivars: @class_ivars, class_cvars: @class_cvars, program_globals: @program_globals,
559
582
  discovered_classes: @discovered_classes, in_source_constants: @in_source_constants,
560
583
  discovered_methods: @discovered_methods, discovered_def_nodes: @discovered_def_nodes,
584
+ discovered_def_sources: @discovered_def_sources,
561
585
  discovered_method_visibilities: @discovered_method_visibilities,
562
586
  discovered_superclasses: @discovered_superclasses,
563
587
  discovered_includes: @discovered_includes,
@@ -576,6 +600,7 @@ module Rigor
576
600
  in_source_constants: in_source_constants,
577
601
  discovered_methods: discovered_methods,
578
602
  discovered_def_nodes: discovered_def_nodes,
603
+ discovered_def_sources: discovered_def_sources,
579
604
  discovered_method_visibilities: discovered_method_visibilities,
580
605
  discovered_superclasses: discovered_superclasses,
581
606
  discovered_includes: discovered_includes,
@@ -607,6 +632,7 @@ module Rigor
607
632
  in_source_constants: in_source_constants,
608
633
  discovered_methods: discovered_methods,
609
634
  discovered_def_nodes: discovered_def_nodes,
635
+ discovered_def_sources: discovered_def_sources,
610
636
  discovered_method_visibilities: discovered_method_visibilities,
611
637
  discovered_superclasses: discovered_superclasses,
612
638
  discovered_includes: discovered_includes,