rigortype 0.1.11 → 0.1.12
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/lib/rigor/analysis/erb_template_detector.rb +38 -0
- data/lib/rigor/analysis/runner.rb +6 -1
- data/lib/rigor/analysis/worker_session.rb +6 -1
- data/lib/rigor/cli/plugins_command.rb +308 -0
- data/lib/rigor/cli/plugins_renderer.rb +173 -0
- data/lib/rigor/cli.rb +28 -0
- data/lib/rigor/inference/block_parameter_binder.rb +35 -0
- data/lib/rigor/inference/expression_typer.rb +69 -30
- data/lib/rigor/inference/indexed_narrowing.rb +187 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
- data/lib/rigor/inference/method_dispatcher.rb +23 -0
- data/lib/rigor/inference/mutation_widening.rb +285 -0
- data/lib/rigor/inference/narrowing.rb +72 -4
- data/lib/rigor/inference/scope_indexer.rb +409 -12
- data/lib/rigor/inference/statement_evaluator.rb +256 -4
- data/lib/rigor/scope.rb +181 -4
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +64 -3
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
- data/sig/rigor/scope.rbs +22 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 182bad9de02b3b4579fe1c385fa740e30f2df85cad36d8c21647dfb09004b9eb
|
|
4
|
+
data.tar.gz: 398d4ebc670530522696122117592bd59b60d7f899ae0a8cd58b11c8506ec608
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: abd25775ea4973023dc7ef7132669a0bf556604c7378caef60aa3c2fbae4ef79bc2651cd3499cecb8046507214dbdc4cb56bdb953c301ec42b0ebb86291202b6
|
|
7
|
+
data.tar.gz: 9281f16a4b2d39847aaaf408844920dfb9f2e6a914df3544182d6d3832f28f03ca63d8a19aa54ca80977f3431351717a59d7ba704f3361ee92697be21d6193ab
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Analysis
|
|
5
|
+
# Detects when a `.rb` file is actually an ERB template — a Rails
|
|
6
|
+
# generator `templates/foo.rb` shape that uses `<%= ... %>` to
|
|
7
|
+
# interpolate identifiers. Prism rejects the template as invalid
|
|
8
|
+
# Ruby and the analyzer emits one parse-error diagnostic per scrap
|
|
9
|
+
# the parser tripped over (≈ 20 on Redmine's
|
|
10
|
+
# `lib/generators/redmine_plugin_model/templates/migration.rb`),
|
|
11
|
+
# all of them noise from the user's perspective. The runner / worker
|
|
12
|
+
# consults this detector before turning parse errors into
|
|
13
|
+
# diagnostics; an ERB-shaped source is silently skipped.
|
|
14
|
+
#
|
|
15
|
+
# Detection is byte-level on the raw source: any occurrence of the
|
|
16
|
+
# `%>` closing marker proves the file is an ERB template. `%>`
|
|
17
|
+
# cannot appear in valid Ruby — `%` is a binary operator that
|
|
18
|
+
# requires an operand on its right side, never `>`. The opening
|
|
19
|
+
# `<%` is ambiguous in principle (`x<%y` parses as `x < %y`, a
|
|
20
|
+
# comparison against a `%`-literal) but a real Ruby file with that
|
|
21
|
+
# shape would still not contain the closing `%>`.
|
|
22
|
+
module ErbTemplateDetector
|
|
23
|
+
ERB_CLOSING_MARKER = /%>/
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# @param parse_result [Prism::ParseResult]
|
|
28
|
+
# @return [Boolean] true when the parsed source looks like an
|
|
29
|
+
# ERB template (parse errors expected; analysis should skip).
|
|
30
|
+
def template?(parse_result)
|
|
31
|
+
source = parse_result.source.source
|
|
32
|
+
return false unless source.is_a?(String) && !source.empty?
|
|
33
|
+
|
|
34
|
+
ERB_CLOSING_MARKER.match?(source)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -20,6 +20,7 @@ require_relative "buffer_binding"
|
|
|
20
20
|
require_relative "check_rules"
|
|
21
21
|
require_relative "dependency_source_inference"
|
|
22
22
|
require_relative "diagnostic"
|
|
23
|
+
require_relative "erb_template_detector"
|
|
23
24
|
require_relative "project_scan"
|
|
24
25
|
require_relative "result"
|
|
25
26
|
require_relative "run_stats"
|
|
@@ -1457,7 +1458,11 @@ module Rigor
|
|
|
1457
1458
|
|
|
1458
1459
|
def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
|
|
1459
1460
|
parse_result = parse_source(path)
|
|
1460
|
-
|
|
1461
|
+
unless parse_result.errors.empty?
|
|
1462
|
+
return [] if ErbTemplateDetector.template?(parse_result)
|
|
1463
|
+
|
|
1464
|
+
return parse_diagnostics(path, parse_result)
|
|
1465
|
+
end
|
|
1461
1466
|
|
|
1462
1467
|
scope = seed_project_scope(Scope.empty(environment: environment, source_path: path))
|
|
1463
1468
|
index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
|
|
@@ -15,6 +15,7 @@ require_relative "../inference/method_dispatcher/file_folding"
|
|
|
15
15
|
require_relative "check_rules"
|
|
16
16
|
require_relative "dependency_source_inference"
|
|
17
17
|
require_relative "diagnostic"
|
|
18
|
+
require_relative "erb_template_detector"
|
|
18
19
|
|
|
19
20
|
module Rigor
|
|
20
21
|
module Analysis
|
|
@@ -158,7 +159,11 @@ module Rigor
|
|
|
158
159
|
# is a per-run aggregate concern handled by the caller.
|
|
159
160
|
def analyze(path)
|
|
160
161
|
parse_result = parse_source(path)
|
|
161
|
-
|
|
162
|
+
unless parse_result.errors.empty?
|
|
163
|
+
return [] if ErbTemplateDetector.template?(parse_result)
|
|
164
|
+
|
|
165
|
+
return parse_diagnostics(path, parse_result)
|
|
166
|
+
end
|
|
162
167
|
|
|
163
168
|
scope = Scope.empty(environment: @environment, source_path: path)
|
|
164
169
|
index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
require_relative "../configuration"
|
|
6
|
+
require_relative "../plugin"
|
|
7
|
+
require_relative "../plugin/loader"
|
|
8
|
+
require_relative "../plugin/services"
|
|
9
|
+
require_relative "../reflection"
|
|
10
|
+
require_relative "../type/combinator"
|
|
11
|
+
require_relative "plugins_renderer"
|
|
12
|
+
|
|
13
|
+
module Rigor
|
|
14
|
+
class CLI
|
|
15
|
+
# `rigor plugins` — reports the activation status of every
|
|
16
|
+
# plugin entry in `.rigor.yml` so users (and the
|
|
17
|
+
# `rigor-project-init` SKILL, CI gates, `rigor init`) can
|
|
18
|
+
# verify their plugin configuration is actually doing what
|
|
19
|
+
# they think.
|
|
20
|
+
#
|
|
21
|
+
# The command is read-only and idempotent: it loads the
|
|
22
|
+
# project's `.rigor.yml` (same discovery as `rigor check`),
|
|
23
|
+
# runs `Plugin::Loader.load` to attempt instantiation, then
|
|
24
|
+
# prints a table of:
|
|
25
|
+
#
|
|
26
|
+
# - load status (`loaded` / `load-error` with reason);
|
|
27
|
+
# - resolved manifest id, version, description;
|
|
28
|
+
# - `signature_paths:` (absolute paths + per-dir `.rbs` count);
|
|
29
|
+
# - every manifest-declared extension surface
|
|
30
|
+
# (`open_receivers:` / `owns_receivers:` / `produces:` /
|
|
31
|
+
# `consumes:` / `block_as_methods:` / `heredoc_templates:` /
|
|
32
|
+
# `trait_registries:` / `external_files:` /
|
|
33
|
+
# `type_node_resolvers:` / `hkt_registrations:` /
|
|
34
|
+
# `hkt_definitions:` / `protocol_contracts:` /
|
|
35
|
+
# `source_rbs_synthesizer:`).
|
|
36
|
+
#
|
|
37
|
+
# Output formats: `text` (default, human-readable table) and
|
|
38
|
+
# `json` (for tooling — SKILLs, CI gates, editor integrations).
|
|
39
|
+
#
|
|
40
|
+
# Exit codes:
|
|
41
|
+
# - `0` — every configured plugin loaded.
|
|
42
|
+
# - `1` — at least one plugin failed to load AND `--strict`
|
|
43
|
+
# was passed. Without `--strict` the command always exits 0;
|
|
44
|
+
# load errors are reported but not treated as a gate failure
|
|
45
|
+
# (matching `rigor triage`'s advisory shape).
|
|
46
|
+
#
|
|
47
|
+
# Future expansion (not in this slice):
|
|
48
|
+
# - Per-plugin diagnostic counts (would require running the
|
|
49
|
+
# full analysis pipeline; out of scope for an inspection
|
|
50
|
+
# command).
|
|
51
|
+
# - Verification that `signature_paths` actually merged into
|
|
52
|
+
# the RBS environment without conflict (requires constructing
|
|
53
|
+
# the Environment, which is heavier than the loader-only
|
|
54
|
+
# pass this slice does).
|
|
55
|
+
class PluginsCommand
|
|
56
|
+
USAGE = "Usage: rigor plugins [options]"
|
|
57
|
+
|
|
58
|
+
def initialize(argv:, out: $stdout, err: $stderr)
|
|
59
|
+
@argv = argv
|
|
60
|
+
@out = out
|
|
61
|
+
@err = err
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Integer] CLI exit status.
|
|
65
|
+
def run
|
|
66
|
+
options = parse_options
|
|
67
|
+
config_path = options.fetch(:config) || Configuration.discover
|
|
68
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
69
|
+
rows = build_rows(configuration)
|
|
70
|
+
|
|
71
|
+
renderer = PluginsRenderer.new(rows: rows, configuration_path: config_path)
|
|
72
|
+
@out.puts(options.fetch(:format) == "json" ? renderer.json : renderer.text)
|
|
73
|
+
|
|
74
|
+
any_load_errors = rows.any? { |row| row.fetch(:status) == :load_error }
|
|
75
|
+
return 1 if any_load_errors && options.fetch(:strict)
|
|
76
|
+
|
|
77
|
+
0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def parse_options
|
|
83
|
+
options = { config: nil, format: "text", strict: false }
|
|
84
|
+
OptionParser.new do |opts|
|
|
85
|
+
opts.banner = USAGE
|
|
86
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
|
|
87
|
+
opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
|
|
88
|
+
opts.on("--strict", "Exit 1 if any plugin failed to load (CI gate)") { options[:strict] = true }
|
|
89
|
+
end.parse!(@argv)
|
|
90
|
+
validate!(options)
|
|
91
|
+
options
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate!(options)
|
|
95
|
+
return if %w[text json].include?(options.fetch(:format))
|
|
96
|
+
|
|
97
|
+
raise OptionParser::InvalidArgument, "unsupported format: #{options.fetch(:format)}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Runs the plugin loader against the project configuration
|
|
101
|
+
# and returns a row per declared plugin entry. Each row is
|
|
102
|
+
# a plain Hash with the fields enumerated in the class
|
|
103
|
+
# docstring so the renderer (text + JSON) reads from a
|
|
104
|
+
# single shape.
|
|
105
|
+
#
|
|
106
|
+
# The loader catches require / init failures and surfaces
|
|
107
|
+
# them through `registry.load_errors`; we merge those back
|
|
108
|
+
# into the per-entry rows by matching on `plugin_ref`.
|
|
109
|
+
def build_rows(configuration)
|
|
110
|
+
services = build_services(configuration)
|
|
111
|
+
registry = Plugin::Loader.load(configuration: configuration, services: services)
|
|
112
|
+
|
|
113
|
+
rows = configuration.plugins.map { |entry| row_for_entry(entry, registry) }
|
|
114
|
+
# Surface registry-level errors that did not bind to an
|
|
115
|
+
# entry (e.g. dependency-cycle errors that name multiple
|
|
116
|
+
# plugins). The renderer treats these as "orphan" errors.
|
|
117
|
+
orphan_errors = orphan_load_errors(registry, rows)
|
|
118
|
+
rows + orphan_errors
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def build_services(configuration)
|
|
122
|
+
Plugin::Services.new(
|
|
123
|
+
reflection: Reflection,
|
|
124
|
+
type: Type::Combinator,
|
|
125
|
+
configuration: configuration,
|
|
126
|
+
cache_store: nil
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def row_for_entry(entry, registry)
|
|
131
|
+
gem_name = entry_gem_name(entry)
|
|
132
|
+
config = entry_config(entry)
|
|
133
|
+
plugin = registry.plugins.find do |p|
|
|
134
|
+
# The loader has already matched the entry to a plugin
|
|
135
|
+
# class; we can identify it by either the gem name (when
|
|
136
|
+
# the entry was a String) or the explicit id (when the
|
|
137
|
+
# entry was a Hash with `id:`).
|
|
138
|
+
plugin_matches_entry?(p, gem_name, entry_id(entry))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if plugin
|
|
142
|
+
loaded_row(plugin, gem_name, config)
|
|
143
|
+
else
|
|
144
|
+
# Find the load error whose plugin_ref names this entry
|
|
145
|
+
# (the ref is set by Loader to the gem name on require
|
|
146
|
+
# failures and to the manifest id on later failures).
|
|
147
|
+
error = registry.load_errors.find do |e|
|
|
148
|
+
ref = e.plugin_ref.to_s
|
|
149
|
+
ref == gem_name || ref == entry_id(entry).to_s
|
|
150
|
+
end
|
|
151
|
+
load_error_row(gem_name, entry_id(entry), config, error)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def plugin_matches_entry?(plugin, gem_name, entry_id)
|
|
156
|
+
return true if entry_id && plugin.manifest.id == entry_id
|
|
157
|
+
|
|
158
|
+
# A bare gem entry conventionally has manifest.id equal to
|
|
159
|
+
# the gem name with the `rigor-` prefix stripped.
|
|
160
|
+
derived_id = gem_name.delete_prefix("rigor-")
|
|
161
|
+
[derived_id, gem_name].include?(plugin.manifest.id)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def loaded_row(plugin, gem_name, config)
|
|
165
|
+
manifest = plugin.manifest
|
|
166
|
+
identity_fields(gem_name, manifest, config)
|
|
167
|
+
.merge(extension_fields(plugin, manifest))
|
|
168
|
+
.merge(load_error: nil)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def identity_fields(gem_name, manifest, config)
|
|
172
|
+
{
|
|
173
|
+
gem: gem_name,
|
|
174
|
+
status: :loaded,
|
|
175
|
+
id: manifest.id,
|
|
176
|
+
version: manifest.version,
|
|
177
|
+
description: manifest.description,
|
|
178
|
+
config: config
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def extension_fields(plugin, manifest)
|
|
183
|
+
{
|
|
184
|
+
signature_paths: signature_path_rows(plugin),
|
|
185
|
+
open_receivers: manifest.open_receivers.dup,
|
|
186
|
+
owns_receivers: manifest.owns_receivers.dup,
|
|
187
|
+
produces: manifest.produces.map(&:to_s),
|
|
188
|
+
consumes: manifest.consumes.map { |c| "#{c.plugin_id}/#{c.name}#{'?' if c.optional}" },
|
|
189
|
+
source_rbs_synthesizer: !manifest.source_rbs_synthesizer.nil?
|
|
190
|
+
}.merge(extension_count_fields(manifest))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def extension_count_fields(manifest)
|
|
194
|
+
{
|
|
195
|
+
block_as_methods: manifest.block_as_methods.size,
|
|
196
|
+
heredoc_templates: manifest.heredoc_templates.size,
|
|
197
|
+
trait_registries: manifest.trait_registries.size,
|
|
198
|
+
external_files: manifest.external_files.size,
|
|
199
|
+
type_node_resolvers: manifest.type_node_resolvers.size,
|
|
200
|
+
hkt_registrations: manifest.hkt_registrations.size,
|
|
201
|
+
hkt_definitions: manifest.hkt_definitions.size,
|
|
202
|
+
protocol_contracts: manifest.protocol_contracts.size
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def load_error_row(gem_name, entry_id, config, error)
|
|
207
|
+
{
|
|
208
|
+
gem: gem_name,
|
|
209
|
+
status: :load_error,
|
|
210
|
+
id: entry_id,
|
|
211
|
+
version: nil,
|
|
212
|
+
description: nil,
|
|
213
|
+
config: config,
|
|
214
|
+
signature_paths: [],
|
|
215
|
+
open_receivers: [],
|
|
216
|
+
owns_receivers: [],
|
|
217
|
+
produces: [],
|
|
218
|
+
consumes: [],
|
|
219
|
+
block_as_methods: 0,
|
|
220
|
+
heredoc_templates: 0,
|
|
221
|
+
trait_registries: 0,
|
|
222
|
+
external_files: 0,
|
|
223
|
+
type_node_resolvers: 0,
|
|
224
|
+
hkt_registrations: 0,
|
|
225
|
+
hkt_definitions: 0,
|
|
226
|
+
protocol_contracts: 0,
|
|
227
|
+
source_rbs_synthesizer: false,
|
|
228
|
+
load_error: error&.message || "plugin did not register or could not be matched to a registered class"
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# For each `signature_paths:` directory the plugin resolves
|
|
233
|
+
# against its gem root, report the absolute path and a
|
|
234
|
+
# quick `.rbs`-file count. The count is a sanity signal:
|
|
235
|
+
# an empty bundle directory loads silently today but
|
|
236
|
+
# contributes nothing.
|
|
237
|
+
def signature_path_rows(plugin)
|
|
238
|
+
plugin.signature_paths.map do |abs|
|
|
239
|
+
{
|
|
240
|
+
path: abs,
|
|
241
|
+
exists: File.directory?(abs),
|
|
242
|
+
rbs_files: rbs_file_count(abs)
|
|
243
|
+
}
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def rbs_file_count(dir)
|
|
248
|
+
return 0 unless File.directory?(dir)
|
|
249
|
+
|
|
250
|
+
Dir.glob(File.join(dir, "**", "*.rbs")).size
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def entry_gem_name(entry)
|
|
254
|
+
case entry
|
|
255
|
+
when String then entry
|
|
256
|
+
when Hash
|
|
257
|
+
string_keyed = entry.to_h { |k, v| [k.to_s, v] }
|
|
258
|
+
(string_keyed["gem"] || string_keyed["id"]).to_s
|
|
259
|
+
else entry.to_s
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def entry_id(entry)
|
|
264
|
+
case entry
|
|
265
|
+
when Hash
|
|
266
|
+
string_keyed = entry.to_h { |k, v| [k.to_s, v] }
|
|
267
|
+
string_keyed["id"]
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def entry_config(entry)
|
|
272
|
+
case entry
|
|
273
|
+
when Hash
|
|
274
|
+
string_keyed = entry.to_h { |k, v| [k.to_s, v] }
|
|
275
|
+
string_keyed["config"] || {}
|
|
276
|
+
else {}
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Build orphan-error rows for load errors whose `plugin_ref`
|
|
281
|
+
# did not bind to any configured plugin entry (e.g. a
|
|
282
|
+
# dependency-cycle naming a plugin we already accounted for
|
|
283
|
+
# but reported alongside another). De-duplicates against
|
|
284
|
+
# errors we already attached.
|
|
285
|
+
def orphan_load_errors(registry, rows)
|
|
286
|
+
attached_refs = rows.compact.flat_map { |row| [row[:gem], row[:id]].compact }.to_set
|
|
287
|
+
unattached = registry.load_errors.reject { |error| attached_refs.include?(error.plugin_ref.to_s) }
|
|
288
|
+
unattached.map do |error|
|
|
289
|
+
{
|
|
290
|
+
gem: error.plugin_ref.to_s,
|
|
291
|
+
status: :load_error,
|
|
292
|
+
id: nil,
|
|
293
|
+
version: nil,
|
|
294
|
+
description: nil,
|
|
295
|
+
config: {},
|
|
296
|
+
signature_paths: [],
|
|
297
|
+
open_receivers: [], owns_receivers: [], produces: [], consumes: [],
|
|
298
|
+
block_as_methods: 0, heredoc_templates: 0, trait_registries: 0,
|
|
299
|
+
external_files: 0, type_node_resolvers: 0,
|
|
300
|
+
hkt_registrations: 0, hkt_definitions: 0,
|
|
301
|
+
protocol_contracts: 0, source_rbs_synthesizer: false,
|
|
302
|
+
load_error: error.message
|
|
303
|
+
}
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
class CLI
|
|
7
|
+
# Renderer for `rigor plugins`. Produces a human-readable
|
|
8
|
+
# text table and a JSON representation from the same row
|
|
9
|
+
# shape (the Hash documented on
|
|
10
|
+
# {Rigor::CLI::PluginsCommand#loaded_row}).
|
|
11
|
+
#
|
|
12
|
+
# The two formats carry the same content; JSON is meant for
|
|
13
|
+
# tooling (SKILLs, CI, editor integrations) while text is
|
|
14
|
+
# for interactive inspection. Rows are printed in the order
|
|
15
|
+
# the loader resolved them.
|
|
16
|
+
class PluginsRenderer
|
|
17
|
+
def initialize(rows:, configuration_path:)
|
|
18
|
+
@rows = rows
|
|
19
|
+
@configuration_path = configuration_path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def text
|
|
23
|
+
lines = []
|
|
24
|
+
lines << header
|
|
25
|
+
lines << ""
|
|
26
|
+
@rows.each_with_index do |row, index|
|
|
27
|
+
lines.concat(row_lines(row))
|
|
28
|
+
lines << "" unless index == @rows.size - 1
|
|
29
|
+
end
|
|
30
|
+
lines << ""
|
|
31
|
+
lines << footer
|
|
32
|
+
lines.join("\n")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def json
|
|
36
|
+
JSON.pretty_generate(
|
|
37
|
+
{
|
|
38
|
+
"configuration" => @configuration_path,
|
|
39
|
+
"plugins" => @rows.map { |row| row_json(row) },
|
|
40
|
+
"summary" => summary
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def header
|
|
48
|
+
loaded = @rows.count { |r| r[:status] == :loaded }
|
|
49
|
+
errored = @rows.count { |r| r[:status] == :load_error }
|
|
50
|
+
config = @configuration_path || "(no .rigor.yml found; using defaults)"
|
|
51
|
+
"Plugin activation report\n " \
|
|
52
|
+
"configuration: #{config}\n " \
|
|
53
|
+
"loaded: #{loaded} load-error: #{errored}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def footer
|
|
57
|
+
errored = @rows.select { |r| r[:status] == :load_error }
|
|
58
|
+
if errored.empty?
|
|
59
|
+
"All configured plugins loaded successfully."
|
|
60
|
+
else
|
|
61
|
+
"#{errored.size} plugin(s) failed to load — see above. " \
|
|
62
|
+
"Run with --strict to make this a CI gate."
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def row_lines(row)
|
|
67
|
+
marker = row[:status] == :loaded ? "OK " : "ERR"
|
|
68
|
+
head = if row[:status] == :loaded
|
|
69
|
+
" [#{marker}] #{row[:id]} v#{row[:version]} (#{row[:gem]})"
|
|
70
|
+
else
|
|
71
|
+
" [#{marker}] #{row[:gem]}"
|
|
72
|
+
end
|
|
73
|
+
lines = [head]
|
|
74
|
+
lines << " #{row[:description]}" if row[:description]
|
|
75
|
+
|
|
76
|
+
if row[:status] == :load_error
|
|
77
|
+
lines << " load error: #{row[:load_error]}"
|
|
78
|
+
lines << " config: #{row[:config].inspect}" if row[:config] && !row[:config].empty?
|
|
79
|
+
return lines
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
lines.concat(loaded_detail_lines(row))
|
|
83
|
+
lines
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def loaded_detail_lines(row)
|
|
87
|
+
lines = []
|
|
88
|
+
lines.concat(signature_paths_lines(row[:signature_paths])) if row[:signature_paths].any?
|
|
89
|
+
lines.concat(receiver_and_fact_lines(row))
|
|
90
|
+
lines.concat(macro_substrate_lines(row))
|
|
91
|
+
lines.concat(extra_surface_lines(row))
|
|
92
|
+
lines << " config: #{row[:config].inspect}" if row[:config] && !row[:config].empty?
|
|
93
|
+
lines
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def receiver_and_fact_lines(row)
|
|
97
|
+
lines = []
|
|
98
|
+
lines << " open_receivers: #{row[:open_receivers].join(', ')}" if row[:open_receivers].any?
|
|
99
|
+
lines << " owns_receivers: #{row[:owns_receivers].join(', ')}" if row[:owns_receivers].any?
|
|
100
|
+
lines << " produces: #{row[:produces].join(', ')}" if row[:produces].any?
|
|
101
|
+
lines << " consumes: #{row[:consumes].join(', ')}" if row[:consumes].any?
|
|
102
|
+
lines
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def extra_surface_lines(row)
|
|
106
|
+
lines = []
|
|
107
|
+
lines << " protocol_contracts: #{row[:protocol_contracts]}" if row[:protocol_contracts].positive?
|
|
108
|
+
lines << " source_rbs_synthesizer: yes" if row[:source_rbs_synthesizer]
|
|
109
|
+
lines << " type_node_resolvers: #{row[:type_node_resolvers]}" if row[:type_node_resolvers].positive?
|
|
110
|
+
if row[:hkt_registrations].positive? || row[:hkt_definitions].positive?
|
|
111
|
+
lines << " hkt: #{row[:hkt_registrations]} registration(s), #{row[:hkt_definitions]} definition(s)"
|
|
112
|
+
end
|
|
113
|
+
lines
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def signature_paths_lines(paths)
|
|
117
|
+
lines = [" signature_paths:"]
|
|
118
|
+
paths.each do |entry|
|
|
119
|
+
marker = entry[:exists] ? "" : " (MISSING)"
|
|
120
|
+
lines << " #{entry[:path]} (#{entry[:rbs_files]} .rbs file(s))#{marker}"
|
|
121
|
+
end
|
|
122
|
+
lines
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def macro_substrate_lines(row)
|
|
126
|
+
parts = []
|
|
127
|
+
parts << "block_as_methods=#{row[:block_as_methods]}" if row[:block_as_methods].positive?
|
|
128
|
+
parts << "heredoc_templates=#{row[:heredoc_templates]}" if row[:heredoc_templates].positive?
|
|
129
|
+
parts << "trait_registries=#{row[:trait_registries]}" if row[:trait_registries].positive?
|
|
130
|
+
parts << "external_files=#{row[:external_files]}" if row[:external_files].positive?
|
|
131
|
+
return [] if parts.empty?
|
|
132
|
+
|
|
133
|
+
[" macro substrate: #{parts.join(', ')}"]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def row_json(row)
|
|
137
|
+
{
|
|
138
|
+
"gem" => row[:gem],
|
|
139
|
+
"status" => row[:status].to_s,
|
|
140
|
+
"id" => row[:id],
|
|
141
|
+
"version" => row[:version],
|
|
142
|
+
"description" => row[:description],
|
|
143
|
+
"config" => row[:config],
|
|
144
|
+
"signature_paths" => row[:signature_paths].map do |sp|
|
|
145
|
+
{ "path" => sp[:path], "exists" => sp[:exists], "rbs_files" => sp[:rbs_files] }
|
|
146
|
+
end,
|
|
147
|
+
"open_receivers" => row[:open_receivers],
|
|
148
|
+
"owns_receivers" => row[:owns_receivers],
|
|
149
|
+
"produces" => row[:produces],
|
|
150
|
+
"consumes" => row[:consumes],
|
|
151
|
+
"block_as_methods" => row[:block_as_methods],
|
|
152
|
+
"heredoc_templates" => row[:heredoc_templates],
|
|
153
|
+
"trait_registries" => row[:trait_registries],
|
|
154
|
+
"external_files" => row[:external_files],
|
|
155
|
+
"type_node_resolvers" => row[:type_node_resolvers],
|
|
156
|
+
"hkt_registrations" => row[:hkt_registrations],
|
|
157
|
+
"hkt_definitions" => row[:hkt_definitions],
|
|
158
|
+
"protocol_contracts" => row[:protocol_contracts],
|
|
159
|
+
"source_rbs_synthesizer" => row[:source_rbs_synthesizer],
|
|
160
|
+
"load_error" => row[:load_error]
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def summary
|
|
165
|
+
{
|
|
166
|
+
"total" => @rows.size,
|
|
167
|
+
"loaded" => @rows.count { |r| r[:status] == :loaded },
|
|
168
|
+
"load_error" => @rows.count { |r| r[:status] == :load_error }
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -32,6 +32,7 @@ module Rigor
|
|
|
32
32
|
"baseline" => :run_baseline,
|
|
33
33
|
"triage" => :run_triage,
|
|
34
34
|
"coverage" => :run_coverage,
|
|
35
|
+
"plugins" => :run_plugins,
|
|
35
36
|
"playground" => :run_playground
|
|
36
37
|
}.freeze
|
|
37
38
|
|
|
@@ -475,9 +476,29 @@ module Rigor
|
|
|
475
476
|
|
|
476
477
|
File.write(path, init_template)
|
|
477
478
|
@out.puts("Created #{path}")
|
|
479
|
+
print_init_next_steps(path)
|
|
478
480
|
0
|
|
479
481
|
end
|
|
480
482
|
|
|
483
|
+
# `rigor init`'s template ships empty `plugins:` so a fresh
|
|
484
|
+
# init has nothing to validate — but the moment the user adds
|
|
485
|
+
# any plugin entry, the activation-failure surfaces enumerated
|
|
486
|
+
# in `rigor plugins`'s docstring become real. Point them at
|
|
487
|
+
# the verification command + the canonical readiness flow so
|
|
488
|
+
# silent failures (the cwd / Gemfile / signature_paths
|
|
489
|
+
# mismatches that surfaced during the Mastodon trial) get
|
|
490
|
+
# caught the first time the user wires a plugin, not the first
|
|
491
|
+
# time `rigor check` reports false positives that should have
|
|
492
|
+
# been covered.
|
|
493
|
+
def print_init_next_steps(path)
|
|
494
|
+
@out.puts ""
|
|
495
|
+
@out.puts "Next steps:"
|
|
496
|
+
@out.puts " 1. Edit #{path} — add the `plugins:` your project needs."
|
|
497
|
+
@out.puts " 2. Run `rigor plugins` to verify every configured plugin loads."
|
|
498
|
+
@out.puts " (`--strict` exits 1 on failure; ideal CI gate.)"
|
|
499
|
+
@out.puts " 3. Run `rigor check` to analyse your code."
|
|
500
|
+
end
|
|
501
|
+
|
|
481
502
|
# Renders the starter `.rigor.yml` body. The template
|
|
482
503
|
# serialises `Configuration::DEFAULTS` (so the on-disk file
|
|
483
504
|
# round-trips through `Configuration.load`) and prepends a
|
|
@@ -597,6 +618,12 @@ module Rigor
|
|
|
597
618
|
CLI::CoverageCommand.new(argv: @argv, out: @out, err: @err).run
|
|
598
619
|
end
|
|
599
620
|
|
|
621
|
+
def run_plugins
|
|
622
|
+
require_relative "cli/plugins_command"
|
|
623
|
+
|
|
624
|
+
CLI::PluginsCommand.new(argv: @argv, out: @out, err: @err).run
|
|
625
|
+
end
|
|
626
|
+
|
|
600
627
|
def run_playground
|
|
601
628
|
begin
|
|
602
629
|
require "rigor/playground"
|
|
@@ -653,6 +680,7 @@ module Rigor
|
|
|
653
680
|
mcp Run the Rigor MCP server over stdio (ADR-33)
|
|
654
681
|
triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
|
|
655
682
|
coverage Report type-precision coverage (precise vs Dynamic ratio)
|
|
683
|
+
plugins Report activation status of every configured plugin
|
|
656
684
|
playground Start the browser playground (requires rigor-playground gem)
|
|
657
685
|
version Print the Rigor version
|
|
658
686
|
help Print this help
|
|
@@ -106,6 +106,8 @@ module Rigor
|
|
|
106
106
|
params_node = params_root.parameters
|
|
107
107
|
return {} if params_node.nil?
|
|
108
108
|
|
|
109
|
+
apply_auto_splat(params_node)
|
|
110
|
+
|
|
109
111
|
bindings = {}
|
|
110
112
|
bind_positionals(params_node, bindings, 0)
|
|
111
113
|
bind_rest(params_node, bindings)
|
|
@@ -115,6 +117,39 @@ module Rigor
|
|
|
115
117
|
bindings
|
|
116
118
|
end
|
|
117
119
|
|
|
120
|
+
# Ruby blocks (NOT lambdas) auto-splat a single yielded
|
|
121
|
+
# Tuple-shaped value when the block declares more than one
|
|
122
|
+
# required positional parameter:
|
|
123
|
+
#
|
|
124
|
+
# { a: 1 }.each { |k, v| ... }
|
|
125
|
+
#
|
|
126
|
+
# yields `[key, value]` as a single arg, but the two-param
|
|
127
|
+
# block sees `k = key, v = value`. RBS / IteratorDispatch
|
|
128
|
+
# encode this as the block taking ONE `[K, V]` Tuple
|
|
129
|
+
# parameter; without this fix-up the binder would assign
|
|
130
|
+
# `k = Tuple[K, V]` and `v = Dynamic[Top]`, and any call
|
|
131
|
+
# on `k.<method-not-on-Tuple>` would false-fire.
|
|
132
|
+
#
|
|
133
|
+
# The rule fires only when (a) the receiver yields exactly
|
|
134
|
+
# one value (`expected_param_types.size == 1`), (b) the
|
|
135
|
+
# block declares more than one positional slot, and (c)
|
|
136
|
+
# that single expected element is a Tuple. Multi-arg yields
|
|
137
|
+
# (e.g. `each_with_index`'s `(element, index)` pair) are
|
|
138
|
+
# NOT auto-splatted — matching Ruby semantics where a
|
|
139
|
+
# multi-arg yield to a `|a, b, c|` block fills the extra
|
|
140
|
+
# slot with nil rather than splatting any element.
|
|
141
|
+
def apply_auto_splat(params_node)
|
|
142
|
+
return unless @expected_param_types.size == 1
|
|
143
|
+
|
|
144
|
+
pos_count = params_node.requireds.size + params_node.optionals.size + params_node.posts.size
|
|
145
|
+
return unless pos_count > 1
|
|
146
|
+
|
|
147
|
+
first = @expected_param_types[0]
|
|
148
|
+
return unless first.is_a?(Type::Tuple)
|
|
149
|
+
|
|
150
|
+
@expected_param_types = first.elements
|
|
151
|
+
end
|
|
152
|
+
|
|
118
153
|
def bind_positionals(params_node, bindings, cursor)
|
|
119
154
|
cursor = bind_required_positionals(params_node, bindings, cursor)
|
|
120
155
|
cursor = bind_optional_positionals(params_node, bindings, cursor)
|