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.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  3. data/lib/rigor/cli/baseline_command.rb +4 -3
  4. data/lib/rigor/cli.rb +16 -3
  5. data/lib/rigor/version.rb +1 -1
  6. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  7. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  8. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  9. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  10. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  11. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  12. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  13. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  14. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  15. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  16. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  17. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  18. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  19. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  20. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  21. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  22. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  23. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  24. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  25. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  26. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  27. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  28. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  29. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  30. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  31. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  32. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  33. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  34. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  35. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  36. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  37. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  38. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  39. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  40. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  41. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  42. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  43. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  44. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  45. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  46. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  47. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  48. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  49. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  50. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  51. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  52. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  53. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  54. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  55. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  56. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  57. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  58. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  59. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  60. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  61. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  62. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  63. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  64. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  65. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  66. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  67. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  68. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  69. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  70. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  71. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  72. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  73. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  74. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  75. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  76. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  77. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  78. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  79. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  80. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  81. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  82. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  83. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  84. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  85. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  86. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  87. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  88. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  89. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  90. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  91. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  92. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  93. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  94. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  95. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  96. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  97. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  98. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  99. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  100. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  102. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  103. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  104. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  105. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  106. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  107. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  108. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  109. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  110. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  111. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  112. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  113. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  114. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  115. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  116. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  117. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  118. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  119. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  120. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  121. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  122. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  123. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  124. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  125. metadata +149 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3425a6183f3c9312517a9a82de77b561907c0afd99f8f77844d08741a766fae8
4
- data.tar.gz: cef2e0e925fb844e4112552626966319c942ff0f31ff6d2af05b04ec8c2ee93f
3
+ metadata.gz: 9c5853d88c57eb0ded87f9fbf801e3cfbbc7672be2a16bcc3171bd7e3f33122c
4
+ data.tar.gz: d3f3b9d936dd4aab4a10c93b7879b34bee26aa1313007619baadb1381b87310c
5
5
  SHA512:
6
- metadata.gz: 749cd171f08f03311d70da91e742b07980974876889d152827ae8e3aa4518d2c2f933475efa889e3c63a7f715eddb96112584445967c3e15fe7c58c38d0ed3cd
7
- data.tar.gz: bb5f032e6663cb137b774bfd18d39ad1498b0913d552a92b86c0256fd8a2fb76c1426d9914f01caf3f78359c96079d8dfc7c84f09e2fb473681cf0889ee46e09
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
- def load(path)
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
- def from_diagnostics(diagnostics, match_mode: :rule)
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
- def group_for_baseline(diagnostics, match_mode)
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
- [diag.path, diag.qualified_rule, nil]
172
+ [rel, diag.qualified_rule, nil]
152
173
  when :message
153
- [diag.path, diag.qualified_rule, message_pattern_for(diag.message)]
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.map(&:to_h),
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.1.10"
4
+ VERSION = "0.1.11"
5
5
  end
@@ -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