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.
- checksums.yaml +4 -4
- data/README.md +6 -0
- data/lib/rigor/analysis/check_rules.rb +403 -5
- data/lib/rigor/analysis/diagnostic.rb +15 -3
- data/lib/rigor/analysis/rule_catalog.rb +80 -0
- data/lib/rigor/analysis/runner.rb +10 -0
- data/lib/rigor/cli/plugin_command.rb +245 -0
- data/lib/rigor/cli.rb +8 -0
- data/lib/rigor/configuration/severity_profile.rb +9 -0
- data/lib/rigor/inference/scope_indexer.rb +59 -21
- data/lib/rigor/scope.rb +27 -1
- data/lib/rigor/triage/catalogue.rb +71 -5
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/scope.rbs +3 -0
- data/skills/rigor-plugin-author/SKILL.md +20 -0
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +59 -21
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +64 -15
- data/skills/rigor-project-init/SKILL.md +72 -7
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +233 -19
- metadata +2 -1
|
@@ -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`
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
|
|
125
|
-
|
|
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}]
|
|
1386
|
+
# @return [Hash{Symbol => Hash}]
|
|
1387
|
+
# `{ def_nodes:, def_sources:, superclasses:, includes: }`
|
|
1371
1388
|
def discovered_def_index_for_paths(paths, buffer: nil)
|
|
1372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1392
|
-
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
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
|