rigortype 0.1.14 → 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
@@ -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,
@@ -22,6 +22,7 @@ module Rigor
22
22
  module_function
23
23
 
24
24
  UNDEFINED_METHOD_RULE = "call.undefined-method"
25
+ UNRESOLVED_TOPLEVEL_RULE = "call.unresolved-toplevel"
25
26
 
26
27
  # `undefined method `foo' for <receiver>`
27
28
  UNDEF_METHOD = /\Aundefined method [`'"]([^`'"]+)['"`] for (.+)\z/
@@ -104,9 +105,19 @@ module Rigor
104
105
  # monkey-patch): a known AR method on `Array[...]` deserves
105
106
  # the precise relation-misinference hint, not the generic
106
107
  # "project core-ext" guess H2 would otherwise claim it for.
108
+ #
109
+ # H2K (known project patch) runs before the generic H2: the
110
+ # engine has already proved the defining site via the
111
+ # `project_definition_site` field (ADR-17), so those
112
+ # diagnostics get the high-confidence file-naming hint rather
113
+ # than the spread-based guess. H7 (unresolved toplevel) runs
114
+ # before the systemic / genuine-bug catch-alls so toplevel
115
+ # resolution misses route to `pre_eval:` (ADR-34) instead of
116
+ # reading as scattered bugs.
107
117
  def recognisers
108
118
  %i[h1_activesupport h4_ar_relation h3_gem_without_rbs
109
- h2_monkey_patch h5_systemic_cluster h6_genuine_bugs]
119
+ h2k_known_project_patch h2_monkey_patch h7_unresolved_toplevel
120
+ h5_systemic_cluster h6_genuine_bugs]
110
121
  end
111
122
 
112
123
  # --- H1 — likely ActiveSupport core_ext --------------------
@@ -127,6 +138,31 @@ module Rigor
127
138
  ), matched]
128
139
  end
129
140
 
141
+ # --- H2K — known project monkey-patch (engine-proven) ------
142
+ # ADR-17 / WD3 slice 4: the `call.undefined-method` rule sets
143
+ # `project_definition_site` when the project itself defines the
144
+ # called method on the receiver class somewhere in the file set
145
+ # (a reopened core/stdlib/gem class the dispatcher does not
146
+ # apply cross-file). That is direct evidence — not a spread
147
+ # heuristic — so this recogniser is `:likely` and names the
148
+ # defining files outright. It runs before the generic H2.
149
+ def h2k_known_project_patch(pool)
150
+ matched = pool.select(&:project_definition_site)
151
+ return nil if matched.empty?
152
+
153
+ files = matched.map { |d| d.project_definition_site.sub(/:\d+\z/, "") }
154
+ .uniq.sort
155
+ [Hint.new(
156
+ id: "project-monkey-patch-known", confidence: :likely,
157
+ diagnostic_count: matched.size,
158
+ summary: "#{matched.size} undefined-method site(s) resolve to project " \
159
+ "definitions in #{files.first(3).join(', ')} — reopened core/" \
160
+ "stdlib/gem classes Rigor does not apply cross-file",
161
+ action: "List #{files.size == 1 ? 'this file' : 'these files'} in " \
162
+ "`.rigor.yml`'s `pre_eval:` (ADR-17): #{files.join(', ')}"
163
+ ), matched]
164
+ end
165
+
130
166
  # --- H2 — likely a project monkey-patch / refinement -------
131
167
  def h2_monkey_patch(pool)
132
168
  groups = undefined_method_groups(pool).select do |(_method, _recv), diags|
@@ -179,6 +215,30 @@ module Rigor
179
215
  ), matched]
180
216
  end
181
217
 
218
+ # --- H7 — unresolved toplevel implicit-self calls ----------
219
+ # ADR-34: `call.unresolved-toplevel` fires on a toplevel
220
+ # implicit-self call (no receiver, outside any def / class /
221
+ # module) that resolves against no visible contributor. The
222
+ # canonical opt-out is `pre_eval:` — the file is usually a
223
+ # script relying on methods defined by a monkey-patch or a
224
+ # required helper Rigor did not walk. Grouped, not per-site,
225
+ # so the report names the cluster once.
226
+ def h7_unresolved_toplevel(pool)
227
+ matched = pool.select { |d| rule_of(d) == UNRESOLVED_TOPLEVEL_RULE }
228
+ return nil if matched.empty?
229
+
230
+ files = matched.map(&:path).uniq.sort
231
+ [Hint.new(
232
+ id: "unresolved-toplevel", confidence: :possible,
233
+ diagnostic_count: matched.size,
234
+ summary: "#{matched.size} toplevel call(s) resolve to nothing visible " \
235
+ "across #{files.size} file(s) (#{top_methods(matched, parser: :toplevel)})",
236
+ action: "If a monkey-patch or required helper defines these, list its " \
237
+ "file in `.rigor.yml`'s `pre_eval:` (ADR-17); otherwise they may " \
238
+ "be genuine typos or missing requires."
239
+ ), matched]
240
+ end
241
+
182
242
  # --- H5 — systemic single-file cluster ---------------------
183
243
  def h5_systemic_cluster(pool)
184
244
  bucket = pool.group_by { |d| [d.path, rule_of(d)] }
@@ -282,10 +342,16 @@ module Rigor
282
342
  groups.keys.first(3).map { |method, recv| "`#{method}` on #{recv}" }.join(", ")
283
343
  end
284
344
 
285
- def top_methods(diagnostics, limit: 5)
286
- diagnostics.filter_map { |d| parse_undefined_method(d)&.fetch(:method) }
287
- .tally.sort_by { |method, count| [-count, method] }
288
- .first(limit).map { |method, count| "#{method}×#{count}" }.join(" ")
345
+ # `parser: :undefined_method` (default) reads the method from
346
+ # the parsed `undefined-method` shape; `parser: :toplevel`
347
+ # reads the structured `method_name` field directly (the
348
+ # `unresolved-toplevel` rule carries no receiver to parse).
349
+ def top_methods(diagnostics, limit: 5, parser: :undefined_method)
350
+ names = diagnostics.filter_map do |d|
351
+ parser == :toplevel ? d.method_name : parse_undefined_method(d)&.fetch(:method)
352
+ end
353
+ names.tally.sort_by { |method, count| [-count, method] }
354
+ .first(limit).map { |method, count| "#{method}×#{count}" }.join(" ")
289
355
  end
290
356
 
291
357
  def rule_of(diag)
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.1.14"
4
+ VERSION = "0.1.15"
5
5
  end
data/sig/rigor/scope.rbs CHANGED
@@ -15,6 +15,7 @@ module Rigor
15
15
  attr_reader in_source_constants: Hash[String, Type::t]
16
16
  attr_reader discovered_methods: Hash[String, Hash[Symbol, Symbol]]
17
17
  attr_reader discovered_def_nodes: Hash[String, Hash[Symbol, untyped]]
18
+ attr_reader discovered_def_sources: Hash[String, Hash[Symbol, String]]
18
19
  attr_reader discovered_method_visibilities: Hash[String, Hash[Symbol, Symbol]]
19
20
  attr_reader discovered_superclasses: Hash[String, String]
20
21
  attr_reader discovered_includes: Hash[String, Array[String]]
@@ -56,7 +57,9 @@ module Rigor
56
57
  def with_discovered_methods: (Hash[String, Hash[Symbol, Symbol]] table) -> Scope
57
58
  def discovered_method?: (String | Symbol class_name, String | Symbol method_name, Symbol kind) -> bool
58
59
  def with_discovered_def_nodes: (Hash[String, Hash[Symbol, untyped]] table) -> Scope
60
+ def with_discovered_def_sources: (Hash[String, Hash[Symbol, String]] table) -> Scope
59
61
  def user_def_for: (String | Symbol class_name, String | Symbol method_name) -> untyped?
62
+ def user_def_site_for: (String | Symbol class_name, String | Symbol method_name) -> String?
60
63
  def top_level_def_for: (String | Symbol method_name) -> untyped?
61
64
  def toplevel?: () -> bool
62
65
  def with_discovered_method_visibilities: (Hash[String, Hash[Symbol, Symbol]] table) -> Scope