rigortype 0.1.10 → 0.1.11
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/baseline.rb +51 -15
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli.rb +16 -3
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
- data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
- data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
- data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
- data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
- data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
- data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
- data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
- data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
- data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
- data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
- data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
- data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
- data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
- data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
- data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
- data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
- data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
- data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
- data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
- data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
- data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
- data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
- data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
- data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
- data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
- data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
- metadata +149 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9c5853d88c57eb0ded87f9fbf801e3cfbbc7672be2a16bcc3171bd7e3f33122c
|
|
4
|
+
data.tar.gz: d3f3b9d936dd4aab4a10c93b7879b34bee26aa1313007619baadb1381b87310c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bebba3258c508b893a7ca22e98b17838bcc7267399882956a4ced9c214e87947754f5a15ecf80029cf601eed58c93fc53ffcb50636002df9f75c00d498a0585b
|
|
7
|
+
data.tar.gz: 4ac6679d930144ffc5a675a9189ed7ce20d500c5ae9d61820b44fcfbf3e02149b615b3b11a44fa48760c17a75b57ec6d6b5ee3e4a80a4b9139514c742c9c612a
|
|
@@ -52,6 +52,15 @@ module Rigor
|
|
|
52
52
|
# CLI or `baseline: <path>` in `.rigor.yml`). The presence
|
|
53
53
|
# of `.rigor-baseline.yml` on disk alone never triggers a
|
|
54
54
|
# load — that's the CLI / Configuration's job to enforce.
|
|
55
|
+
#
|
|
56
|
+
# ## Path handling
|
|
57
|
+
#
|
|
58
|
+
# Baselines store file paths **relative to the project root**
|
|
59
|
+
# (the working directory when `rigor` is run). This makes the
|
|
60
|
+
# generated `.rigor-baseline.yml` portable across machines and
|
|
61
|
+
# checkout locations. When filtering a live diagnostic stream,
|
|
62
|
+
# the instance normalises each diagnostic's absolute path to a
|
|
63
|
+
# relative one before the bucket lookup.
|
|
55
64
|
class Baseline
|
|
56
65
|
# The bucket key is intentionally tuple-shaped so rule-ID
|
|
57
66
|
# rows and message-pattern rows can coexist in a single
|
|
@@ -69,12 +78,16 @@ module Rigor
|
|
|
69
78
|
# path is nil (the caller's "no baseline configured"
|
|
70
79
|
# state). Raises {LoadError} on malformed content;
|
|
71
80
|
# callers translate to a user-facing diagnostic.
|
|
72
|
-
|
|
81
|
+
#
|
|
82
|
+
# `project_root:` is the working directory against which
|
|
83
|
+
# stored relative paths are resolved during filtering.
|
|
84
|
+
# Defaults to `Dir.pwd`.
|
|
85
|
+
def load(path, project_root: Dir.pwd)
|
|
73
86
|
return nil if path.nil?
|
|
74
|
-
return new([]) unless File.exist?(path)
|
|
87
|
+
return new([], project_root: project_root) unless File.exist?(path)
|
|
75
88
|
|
|
76
89
|
raw = YAML.safe_load_file(path, permitted_classes: [Symbol])
|
|
77
|
-
parse_loaded(raw, path: path)
|
|
90
|
+
parse_loaded(raw, path: path, project_root: project_root)
|
|
78
91
|
end
|
|
79
92
|
|
|
80
93
|
# Build a baseline from a current run's diagnostic stream.
|
|
@@ -82,10 +95,14 @@ module Rigor
|
|
|
82
95
|
# message-mode generator passes literal messages through
|
|
83
96
|
# `Regexp.escape` so generated rows never accidentally
|
|
84
97
|
# over-match on punctuation.
|
|
85
|
-
|
|
98
|
+
#
|
|
99
|
+
# `project_root:` is used to convert absolute diagnostic
|
|
100
|
+
# paths to relative paths in the generated YAML. Defaults
|
|
101
|
+
# to `Dir.pwd`.
|
|
102
|
+
def from_diagnostics(diagnostics, match_mode: :rule, project_root: Dir.pwd)
|
|
86
103
|
raise ArgumentError, "match_mode must be :rule or :message" unless %i[rule message].include?(match_mode)
|
|
87
104
|
|
|
88
|
-
grouped = group_for_baseline(diagnostics, match_mode)
|
|
105
|
+
grouped = group_for_baseline(diagnostics, match_mode, project_root)
|
|
89
106
|
buckets = grouped.map do |key, entries|
|
|
90
107
|
Bucket.new(
|
|
91
108
|
file: key[0],
|
|
@@ -94,12 +111,12 @@ module Rigor
|
|
|
94
111
|
count: entries.size
|
|
95
112
|
)
|
|
96
113
|
end
|
|
97
|
-
new(buckets)
|
|
114
|
+
new(buckets, project_root: project_root)
|
|
98
115
|
end
|
|
99
116
|
|
|
100
117
|
private
|
|
101
118
|
|
|
102
|
-
def parse_loaded(raw, path:)
|
|
119
|
+
def parse_loaded(raw, path:, project_root:)
|
|
103
120
|
raise LoadError, "#{path}: expected a Hash at top level, got #{raw.class}" unless raw.is_a?(Hash)
|
|
104
121
|
|
|
105
122
|
version = raw["version"]
|
|
@@ -110,7 +127,8 @@ module Rigor
|
|
|
110
127
|
rows = raw["ignored"] || []
|
|
111
128
|
raise LoadError, "#{path}: `ignored:` must be an Array" unless rows.is_a?(Array)
|
|
112
129
|
|
|
113
|
-
new(rows.each_with_index.map { |row, idx| parse_row(row, path: path, index: idx) }
|
|
130
|
+
new(rows.each_with_index.map { |row, idx| parse_row(row, path: path, index: idx) },
|
|
131
|
+
project_root: project_root)
|
|
114
132
|
end
|
|
115
133
|
|
|
116
134
|
def parse_row(row, path:, index:)
|
|
@@ -141,16 +159,19 @@ module Rigor
|
|
|
141
159
|
# In message mode, each unique message gets its own bucket;
|
|
142
160
|
# in rule mode, every diagnostic for a (file, rule) pair
|
|
143
161
|
# contributes to a single bucket regardless of message.
|
|
144
|
-
|
|
162
|
+
# File paths are normalised to relative before keying.
|
|
163
|
+
def group_for_baseline(diagnostics, match_mode, project_root)
|
|
164
|
+
root = Pathname.new(project_root)
|
|
145
165
|
diagnostics.each_with_object({}) do |diag, into|
|
|
146
166
|
next if diag.qualified_rule.nil?
|
|
147
167
|
next if diag.path.nil?
|
|
148
168
|
|
|
169
|
+
rel = relative_path(diag.path, root)
|
|
149
170
|
key = case match_mode
|
|
150
171
|
when :rule
|
|
151
|
-
[
|
|
172
|
+
[rel, diag.qualified_rule, nil]
|
|
152
173
|
when :message
|
|
153
|
-
[
|
|
174
|
+
[rel, diag.qualified_rule, message_pattern_for(diag.message)]
|
|
154
175
|
end
|
|
155
176
|
(into[key] ||= []) << diag
|
|
156
177
|
end
|
|
@@ -164,13 +185,20 @@ module Rigor
|
|
|
164
185
|
def message_pattern_for(message)
|
|
165
186
|
Regexp.new(Regexp.escape(message.to_s))
|
|
166
187
|
end
|
|
188
|
+
|
|
189
|
+
def relative_path(path, root_pathname)
|
|
190
|
+
Pathname.new(path).relative_path_from(root_pathname).to_s
|
|
191
|
+
rescue ArgumentError
|
|
192
|
+
path # different drive letter on Windows or non-child path
|
|
193
|
+
end
|
|
167
194
|
end
|
|
168
195
|
|
|
169
196
|
class LoadError < StandardError; end
|
|
170
197
|
|
|
171
198
|
attr_reader :buckets
|
|
172
199
|
|
|
173
|
-
def initialize(buckets)
|
|
200
|
+
def initialize(buckets, project_root: Dir.pwd)
|
|
201
|
+
@project_root = Pathname.new(project_root)
|
|
174
202
|
@buckets = buckets.freeze
|
|
175
203
|
# For each (file, qualified_rule) pair, two arrays:
|
|
176
204
|
# - rule-ID rows (message_regex == nil)
|
|
@@ -264,7 +292,7 @@ module Rigor
|
|
|
264
292
|
# cleared buckets (`actual == 0`) from the on-disk file.
|
|
265
293
|
def without(buckets_to_drop)
|
|
266
294
|
dropset = buckets_to_drop.to_set
|
|
267
|
-
self.class.new(buckets.reject { |b| dropset.include?(b) })
|
|
295
|
+
self.class.new(buckets.reject { |b| dropset.include?(b) }, project_root: @project_root)
|
|
268
296
|
end
|
|
269
297
|
|
|
270
298
|
# Serialise to a YAML string. The generator path writes
|
|
@@ -307,6 +335,14 @@ module Rigor
|
|
|
307
335
|
[bucket.file, bucket.rule, bucket.message_regex&.source]
|
|
308
336
|
end
|
|
309
337
|
|
|
338
|
+
def normalize_path(path)
|
|
339
|
+
return path if path.nil?
|
|
340
|
+
|
|
341
|
+
Pathname.new(path).relative_path_from(@project_root).to_s
|
|
342
|
+
rescue ArgumentError
|
|
343
|
+
path # different drive letter on Windows or non-child path
|
|
344
|
+
end
|
|
345
|
+
|
|
310
346
|
def group_diagnostics_for_filtering(diagnostics)
|
|
311
347
|
# First pass: bin each diagnostic into the bucket that
|
|
312
348
|
# claims it. Message-pattern rows take precedence over
|
|
@@ -322,7 +358,7 @@ module Rigor
|
|
|
322
358
|
[bucket.file, bucket.rule,
|
|
323
359
|
bucket.message_regex&.source]
|
|
324
360
|
else
|
|
325
|
-
[diag.path, diag.qualified_rule, :__none__]
|
|
361
|
+
[normalize_path(diag.path), diag.qualified_rule, :__none__]
|
|
326
362
|
end
|
|
327
363
|
bin = (bins[key] ||= { bucket: bucket, diagnostics: [] })
|
|
328
364
|
bin[:diagnostics] << diag
|
|
@@ -331,7 +367,7 @@ module Rigor
|
|
|
331
367
|
end
|
|
332
368
|
|
|
333
369
|
def claim_bucket_for(diagnostic)
|
|
334
|
-
candidates = @by_pair[[diagnostic.path, diagnostic.qualified_rule]]
|
|
370
|
+
candidates = @by_pair[[normalize_path(diagnostic.path), diagnostic.qualified_rule]]
|
|
335
371
|
return nil if candidates.nil? || candidates.empty?
|
|
336
372
|
|
|
337
373
|
# Tighter (message-pattern) buckets first, then the
|
|
@@ -84,7 +84,8 @@ module Rigor
|
|
|
84
84
|
configuration = Configuration.load(options.fetch(:config))
|
|
85
85
|
diagnostics = collect_diagnostics(configuration, options)
|
|
86
86
|
|
|
87
|
-
baseline = Analysis::Baseline.from_diagnostics(diagnostics, match_mode: options.fetch(:match_mode)
|
|
87
|
+
baseline = Analysis::Baseline.from_diagnostics(diagnostics, match_mode: options.fetch(:match_mode),
|
|
88
|
+
project_root: Dir.pwd)
|
|
88
89
|
File.write(path, baseline.to_yaml)
|
|
89
90
|
|
|
90
91
|
@err.puts(
|
|
@@ -149,7 +150,7 @@ module Rigor
|
|
|
149
150
|
defaults = Configuration::DEFAULTS.merge(
|
|
150
151
|
"paths" => configuration.paths,
|
|
151
152
|
"exclude" => configuration.exclude_patterns,
|
|
152
|
-
"plugins" => configuration.plugins
|
|
153
|
+
"plugins" => configuration.plugins,
|
|
153
154
|
"disable" => configuration.disabled_rules,
|
|
154
155
|
"libraries" => configuration.libraries,
|
|
155
156
|
"signature_paths" => configuration.signature_paths,
|
|
@@ -372,7 +373,7 @@ module Rigor
|
|
|
372
373
|
@err.puts("rigor: baseline file not found: #{path}")
|
|
373
374
|
return :error
|
|
374
375
|
end
|
|
375
|
-
Analysis::Baseline.load(path)
|
|
376
|
+
Analysis::Baseline.load(path, project_root: Dir.pwd)
|
|
376
377
|
rescue Analysis::Baseline::LoadError => e
|
|
377
378
|
@err.puts("rigor: baseline load failed: #{e.message}")
|
|
378
379
|
:error
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -31,7 +31,8 @@ module Rigor
|
|
|
31
31
|
"mcp" => :run_mcp,
|
|
32
32
|
"baseline" => :run_baseline,
|
|
33
33
|
"triage" => :run_triage,
|
|
34
|
-
"coverage" => :run_coverage
|
|
34
|
+
"coverage" => :run_coverage,
|
|
35
|
+
"playground" => :run_playground
|
|
35
36
|
}.freeze
|
|
36
37
|
|
|
37
38
|
def self.start(argv = ARGV, out: $stdout, err: $stderr)
|
|
@@ -120,7 +121,7 @@ module Rigor
|
|
|
120
121
|
return false
|
|
121
122
|
end
|
|
122
123
|
|
|
123
|
-
baseline = Analysis::Baseline.load(path)
|
|
124
|
+
baseline = Analysis::Baseline.load(path, project_root: Dir.pwd)
|
|
124
125
|
return false if baseline.nil? || baseline.empty?
|
|
125
126
|
|
|
126
127
|
drifted = baseline.audit(raw_diagnostics).reject { |row| row.status == :within }
|
|
@@ -163,7 +164,7 @@ module Rigor
|
|
|
163
164
|
path = resolve_baseline_path(configuration, options)
|
|
164
165
|
return result if path.nil?
|
|
165
166
|
|
|
166
|
-
baseline = Analysis::Baseline.load(path)
|
|
167
|
+
baseline = Analysis::Baseline.load(path, project_root: Dir.pwd)
|
|
167
168
|
return result if baseline.nil?
|
|
168
169
|
|
|
169
170
|
surfaced, silenced_count = baseline.filter(result.diagnostics)
|
|
@@ -596,6 +597,17 @@ module Rigor
|
|
|
596
597
|
CLI::CoverageCommand.new(argv: @argv, out: @out, err: @err).run
|
|
597
598
|
end
|
|
598
599
|
|
|
600
|
+
def run_playground
|
|
601
|
+
begin
|
|
602
|
+
require "rigor/playground"
|
|
603
|
+
rescue LoadError
|
|
604
|
+
@err.puts "rigor playground requires the rigor-playground gem."
|
|
605
|
+
@err.puts "Install it with: gem install rigor-playground"
|
|
606
|
+
return EXIT_USAGE
|
|
607
|
+
end
|
|
608
|
+
Rigor::CLI::PlaygroundCommand.new(@argv[1..], @out, @err).run
|
|
609
|
+
end
|
|
610
|
+
|
|
599
611
|
def write_result(result, format)
|
|
600
612
|
case format
|
|
601
613
|
when "json"
|
|
@@ -641,6 +653,7 @@ module Rigor
|
|
|
641
653
|
mcp Run the Rigor MCP server over stdio (ADR-33)
|
|
642
654
|
triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
|
|
643
655
|
coverage Report type-precision coverage (precise vs Dynamic ratio)
|
|
656
|
+
playground Start the browser playground (requires rigor-playground gem)
|
|
644
657
|
version Print the Rigor version
|
|
645
658
|
help Print this help
|
|
646
659
|
HELP
|
data/lib/rigor/version.rb
CHANGED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Plugin
|
|
8
|
+
class Actioncable < Rigor::Plugin::Base
|
|
9
|
+
# Walks a parsed file's AST looking for ActionCable
|
|
10
|
+
# entry-point calls and validates each against the
|
|
11
|
+
# {ChannelIndex}.
|
|
12
|
+
#
|
|
13
|
+
# Recognised shapes:
|
|
14
|
+
#
|
|
15
|
+
# - `<ChannelClass>.broadcast_to(record, data)` —
|
|
16
|
+
# class-targeted broadcast. The class must exist in
|
|
17
|
+
# the index.
|
|
18
|
+
# - `ActionCable.server.broadcast(stream_name, data)`
|
|
19
|
+
# — string-targeted broadcast. When `stream_name`
|
|
20
|
+
# is a literal string and the index has at least
|
|
21
|
+
# one channel with no dynamic stream registrations,
|
|
22
|
+
# we check that the name appears in
|
|
23
|
+
# `index.all_stream_names`. Otherwise the
|
|
24
|
+
# `unknown-stream` warning is suppressed (we can't
|
|
25
|
+
# prove absence).
|
|
26
|
+
module Analyzer
|
|
27
|
+
# `ActionCable.server.broadcast(...)` — the receiver
|
|
28
|
+
# path we recognise as a server-targeted broadcast.
|
|
29
|
+
# Single-symbol form (just `broadcast`) is too
|
|
30
|
+
# ambiguous to validate.
|
|
31
|
+
SERVER_BROADCAST_RECEIVER_NAMES = %w[
|
|
32
|
+
ActionCable.server
|
|
33
|
+
::ActionCable.server
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# @param path [String]
|
|
41
|
+
# @param root [Prism::Node]
|
|
42
|
+
# @param channel_index [ChannelIndex]
|
|
43
|
+
# @return [Array<Diagnostic>]
|
|
44
|
+
def diagnose(path:, root:, channel_index:)
|
|
45
|
+
diagnostics = []
|
|
46
|
+
walk(root) do |call_node|
|
|
47
|
+
case call_node.name
|
|
48
|
+
when :broadcast_to
|
|
49
|
+
diagnostics.concat(analyse_broadcast_to(path, call_node, channel_index))
|
|
50
|
+
when :broadcast
|
|
51
|
+
diagnostics.concat(analyse_server_broadcast(path, call_node, channel_index))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
diagnostics
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def walk(node, &)
|
|
58
|
+
return unless node.is_a?(Prism::Node)
|
|
59
|
+
|
|
60
|
+
yield node if node.is_a?(Prism::CallNode)
|
|
61
|
+
node.compact_child_nodes.each { |child| walk(child, &) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def analyse_broadcast_to(path, call_node, channel_index)
|
|
65
|
+
class_name = constant_receiver_name(call_node.receiver)
|
|
66
|
+
return [] if class_name.nil?
|
|
67
|
+
|
|
68
|
+
# broadcast_to with a class-name receiver that
|
|
69
|
+
# doesn't end in "Channel" is almost certainly
|
|
70
|
+
# not ActionCable — pass through silently to
|
|
71
|
+
# avoid flagging unrelated `broadcast_to` methods.
|
|
72
|
+
return [] unless class_name.end_with?("Channel")
|
|
73
|
+
|
|
74
|
+
entry = channel_index.find(class_name) || channel_index.find("::#{class_name}")
|
|
75
|
+
return [unknown_channel_diagnostic(path, call_node, class_name, channel_index)] if entry.nil?
|
|
76
|
+
|
|
77
|
+
[broadcast_target_info(path, call_node, entry)]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def analyse_server_broadcast(path, call_node, channel_index)
|
|
81
|
+
receiver_path = call_chain_string(call_node.receiver)
|
|
82
|
+
return [] unless SERVER_BROADCAST_RECEIVER_NAMES.include?(receiver_path)
|
|
83
|
+
|
|
84
|
+
args = call_node.arguments&.arguments || []
|
|
85
|
+
stream_arg = args.first
|
|
86
|
+
return [] if stream_arg.nil?
|
|
87
|
+
return [] unless stream_arg.is_a?(Prism::StringNode)
|
|
88
|
+
return [] if channel_index.any_dynamic_streams?
|
|
89
|
+
|
|
90
|
+
stream_name = stream_arg.unescaped
|
|
91
|
+
if channel_index.all_stream_names.include?(stream_name)
|
|
92
|
+
return [server_broadcast_info(path, call_node, stream_name)]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
[unknown_stream_diagnostic(path, call_node, stream_name, channel_index)]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def broadcast_target_info(path, call_node, entry)
|
|
99
|
+
location = call_node.location
|
|
100
|
+
Diagnostic.new(
|
|
101
|
+
path: path,
|
|
102
|
+
line: location.start_line,
|
|
103
|
+
column: location.start_column + 1,
|
|
104
|
+
severity: :info,
|
|
105
|
+
rule: "broadcast-target",
|
|
106
|
+
message: "`#{entry.class_name}.broadcast_to(...)` matches discovered channel"
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def server_broadcast_info(path, call_node, stream_name)
|
|
111
|
+
location = call_node.location
|
|
112
|
+
Diagnostic.new(
|
|
113
|
+
path: path,
|
|
114
|
+
line: location.start_line,
|
|
115
|
+
column: location.start_column + 1,
|
|
116
|
+
severity: :info,
|
|
117
|
+
rule: "broadcast-stream",
|
|
118
|
+
message: "`broadcast(\"#{stream_name}\", ...)` matches a registered `stream_from`"
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def unknown_channel_diagnostic(path, call_node, class_name, channel_index)
|
|
123
|
+
location = call_node.location
|
|
124
|
+
suggestions = DidYouMean::SpellChecker.new(dictionary: channel_index.names).correct(class_name)
|
|
125
|
+
suggestion_part = suggestions.empty? ? "" : " (did you mean `#{suggestions.first}`?)"
|
|
126
|
+
Diagnostic.new(
|
|
127
|
+
path: path,
|
|
128
|
+
line: location.start_line,
|
|
129
|
+
column: location.start_column + 1,
|
|
130
|
+
severity: :error,
|
|
131
|
+
rule: "unknown-channel",
|
|
132
|
+
message: "no ActionCable channel `#{class_name}`#{suggestion_part}"
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def unknown_stream_diagnostic(path, call_node, stream_name, channel_index)
|
|
137
|
+
location = call_node.location
|
|
138
|
+
dictionary = channel_index.all_stream_names.to_a
|
|
139
|
+
suggestions = DidYouMean::SpellChecker.new(dictionary: dictionary).correct(stream_name)
|
|
140
|
+
suggestion_part = suggestions.empty? ? "" : " (did you mean `\"#{suggestions.first}\"`?)"
|
|
141
|
+
Diagnostic.new(
|
|
142
|
+
path: path,
|
|
143
|
+
line: location.start_line,
|
|
144
|
+
column: location.start_column + 1,
|
|
145
|
+
severity: :warning,
|
|
146
|
+
rule: "unknown-stream",
|
|
147
|
+
message: "no `stream_from \"#{stream_name}\"` registration in any discovered " \
|
|
148
|
+
"channel#{suggestion_part}"
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Renders an `A.b.c` chain as a string (used to
|
|
153
|
+
# detect `ActionCable.server`). Returns nil for
|
|
154
|
+
# non-chained nodes.
|
|
155
|
+
def call_chain_string(node)
|
|
156
|
+
parts = []
|
|
157
|
+
current = node
|
|
158
|
+
while current.is_a?(Prism::CallNode) && current.arguments.nil?
|
|
159
|
+
parts.unshift(current.name.to_s)
|
|
160
|
+
current = current.receiver
|
|
161
|
+
end
|
|
162
|
+
base = constant_receiver_name(current)
|
|
163
|
+
return nil if base.nil? || parts.empty?
|
|
164
|
+
|
|
165
|
+
[base, *parts].join(".")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def constant_receiver_name(node)
|
|
169
|
+
case node
|
|
170
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
171
|
+
when Prism::ConstantPathNode then constant_path_name(node)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def constant_path_name(node)
|
|
176
|
+
parts = []
|
|
177
|
+
current = node
|
|
178
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
179
|
+
parts.unshift(current.name.to_s)
|
|
180
|
+
current = current.parent
|
|
181
|
+
end
|
|
182
|
+
case current
|
|
183
|
+
when nil then "::#{parts.join('::')}"
|
|
184
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "channel_index"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Plugin
|
|
9
|
+
class Actioncable < Rigor::Plugin::Base
|
|
10
|
+
# Walks the configured channel-search paths via the
|
|
11
|
+
# plugin's `IoBoundary`, parses each `.rb` file with
|
|
12
|
+
# Prism, and collects classes whose immediate
|
|
13
|
+
# superclass is one of the configured base classes.
|
|
14
|
+
#
|
|
15
|
+
# For each discovered channel, the discoverer:
|
|
16
|
+
#
|
|
17
|
+
# - Records every public instance-side `def` whose
|
|
18
|
+
# name isn't an ActionCable framework hook
|
|
19
|
+
# (`subscribed`, `unsubscribed`, `_`-prefixed).
|
|
20
|
+
# These are the action methods clients can invoke
|
|
21
|
+
# via `subscription.perform("action_name", data)`.
|
|
22
|
+
# - Records every literal-string `stream_from "name"`
|
|
23
|
+
# call as a registered stream name.
|
|
24
|
+
# - Sets `dynamic_streams: true` when the channel has
|
|
25
|
+
# ANY non-literal `stream_from` argument (or a
|
|
26
|
+
# `stream_for` call) so the analyzer knows it can't
|
|
27
|
+
# be sure of every stream name.
|
|
28
|
+
#
|
|
29
|
+
# Limitations (intentional for v0.1.0):
|
|
30
|
+
#
|
|
31
|
+
# - Direct-superclass match only.
|
|
32
|
+
# - Public-vs-private is not tracked; the framework
|
|
33
|
+
# hooks (`subscribed`/`unsubscribed`) are excluded
|
|
34
|
+
# by name. Methods marked `private` after a
|
|
35
|
+
# `private` keyword would still appear in the
|
|
36
|
+
# `action_methods` set.
|
|
37
|
+
# - `stream_for(record)` (model-scoped streams) is
|
|
38
|
+
# recognised as setting `dynamic_streams: true` but
|
|
39
|
+
# not introspected further.
|
|
40
|
+
class ChannelDiscoverer
|
|
41
|
+
FRAMEWORK_HOOKS = %i[subscribed unsubscribed].to_set.freeze
|
|
42
|
+
|
|
43
|
+
def initialize(io_boundary:, search_paths:, base_classes:)
|
|
44
|
+
@io_boundary = io_boundary
|
|
45
|
+
@search_paths = search_paths
|
|
46
|
+
@base_classes = base_classes.to_set
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [ChannelIndex]
|
|
50
|
+
def discover
|
|
51
|
+
entries = []
|
|
52
|
+
ruby_files_under(@search_paths).each do |path|
|
|
53
|
+
contents = read_safely(path)
|
|
54
|
+
next if contents.nil?
|
|
55
|
+
|
|
56
|
+
tree = Prism.parse(contents).value
|
|
57
|
+
walk_for_channels(tree, []) do |class_name, body|
|
|
58
|
+
entries << build_entry(class_name, path, body)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
ChannelIndex.new(entries)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def read_safely(path)
|
|
67
|
+
@io_boundary.read_file(path)
|
|
68
|
+
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def ruby_files_under(roots)
|
|
73
|
+
roots.flat_map do |root|
|
|
74
|
+
absolute = File.expand_path(root)
|
|
75
|
+
next [] unless File.directory?(absolute)
|
|
76
|
+
|
|
77
|
+
Dir.glob(File.join(absolute, "**", "*.rb"))
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def walk_for_channels(node, lexical_path, &)
|
|
82
|
+
return if node.nil?
|
|
83
|
+
|
|
84
|
+
case node
|
|
85
|
+
when Prism::ClassNode then visit_class(node, lexical_path, &)
|
|
86
|
+
when Prism::ModuleNode then visit_module(node, lexical_path, &)
|
|
87
|
+
else
|
|
88
|
+
node.compact_child_nodes.each { |child| walk_for_channels(child, lexical_path, &) }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def visit_class(node, lexical_path, &)
|
|
93
|
+
class_local_name = constant_path_name(node.constant_path)
|
|
94
|
+
return if class_local_name.nil?
|
|
95
|
+
|
|
96
|
+
full_name = (lexical_path + [class_local_name]).join("::")
|
|
97
|
+
superclass = constant_path_name(node.superclass) if node.superclass
|
|
98
|
+
yield full_name, node.body if superclass && @base_classes.include?(superclass)
|
|
99
|
+
|
|
100
|
+
inner_path = lexical_path + [class_local_name]
|
|
101
|
+
walk_for_channels(node.body, inner_path, &) if node.body
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def visit_module(node, lexical_path, &)
|
|
105
|
+
module_local_name = constant_path_name(node.constant_path)
|
|
106
|
+
return if module_local_name.nil?
|
|
107
|
+
|
|
108
|
+
inner_path = lexical_path + [module_local_name]
|
|
109
|
+
walk_for_channels(node.body, inner_path, &) if node.body
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_entry(class_name, path, body)
|
|
113
|
+
actions = []
|
|
114
|
+
(body&.compact_child_nodes || []).each do |node|
|
|
115
|
+
actions << node.name if node.is_a?(Prism::DefNode) && action_def?(node)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
stream_names, dynamic_streams = collect_stream_registrations(body)
|
|
119
|
+
|
|
120
|
+
ChannelIndex::Entry.new(
|
|
121
|
+
class_name: class_name,
|
|
122
|
+
file_path: path,
|
|
123
|
+
action_methods: actions.to_set.freeze,
|
|
124
|
+
stream_names: stream_names.to_set.freeze,
|
|
125
|
+
dynamic_streams: dynamic_streams
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Walks the channel body recursively (so
|
|
130
|
+
# `stream_from` / `stream_for` calls inside
|
|
131
|
+
# `subscribed` / helper methods are picked up).
|
|
132
|
+
# Returns `[Array<String>, bool]` — the literal
|
|
133
|
+
# stream names + whether any dynamic registration
|
|
134
|
+
# was seen.
|
|
135
|
+
def collect_stream_registrations(node, names: [], dynamic: false)
|
|
136
|
+
return [names, dynamic] if node.nil?
|
|
137
|
+
|
|
138
|
+
if node.is_a?(Prism::CallNode) && node.receiver.nil?
|
|
139
|
+
case node.name
|
|
140
|
+
when :stream_from
|
|
141
|
+
arg = node.arguments&.arguments&.first
|
|
142
|
+
if arg.is_a?(Prism::StringNode)
|
|
143
|
+
names << arg.unescaped
|
|
144
|
+
else
|
|
145
|
+
dynamic = true
|
|
146
|
+
end
|
|
147
|
+
when :stream_for
|
|
148
|
+
# Model-scoped stream — name is computed from
|
|
149
|
+
# the record at runtime; treat as dynamic.
|
|
150
|
+
dynamic = true
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
node.compact_child_nodes.each do |child|
|
|
155
|
+
names, dynamic = collect_stream_registrations(child, names: names, dynamic: dynamic)
|
|
156
|
+
end
|
|
157
|
+
[names, dynamic]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def action_def?(node)
|
|
161
|
+
return false if node.receiver.is_a?(Prism::SelfNode)
|
|
162
|
+
return false if FRAMEWORK_HOOKS.include?(node.name)
|
|
163
|
+
return false if node.name.to_s.start_with?("_")
|
|
164
|
+
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def constant_path_name(node)
|
|
169
|
+
return nil if node.nil?
|
|
170
|
+
|
|
171
|
+
case node
|
|
172
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
173
|
+
when Prism::ConstantPathNode
|
|
174
|
+
parts = []
|
|
175
|
+
current = node
|
|
176
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
177
|
+
parts.unshift(current.name.to_s)
|
|
178
|
+
current = current.parent
|
|
179
|
+
end
|
|
180
|
+
case current
|
|
181
|
+
when nil then "::#{parts.join('::')}"
|
|
182
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|