rigortype 0.1.10 → 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.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/baseline_command.rb +4 -3
  7. data/lib/rigor/cli/plugins_command.rb +308 -0
  8. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  9. data/lib/rigor/cli.rb +44 -3
  10. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  11. data/lib/rigor/inference/expression_typer.rb +69 -30
  12. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  13. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  14. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  15. data/lib/rigor/inference/mutation_widening.rb +285 -0
  16. data/lib/rigor/inference/narrowing.rb +72 -4
  17. data/lib/rigor/inference/scope_indexer.rb +409 -12
  18. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  19. data/lib/rigor/scope.rb +181 -4
  20. data/lib/rigor/version.rb +1 -1
  21. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  22. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  23. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  24. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  25. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  26. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
  27. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
  28. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
  29. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
  30. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
  32. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
  33. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
  34. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
  35. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  36. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  37. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  38. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  39. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  40. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  41. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
  42. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  43. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  44. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  45. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
  46. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
  47. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
  48. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  49. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  50. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  53. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  54. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  55. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
  56. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  57. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
  58. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  59. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  60. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  62. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  63. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  64. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  65. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  66. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  67. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  68. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  69. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  70. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  71. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  72. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  73. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  74. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  75. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  76. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  77. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  78. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  79. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  80. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  81. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  82. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  83. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  84. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  85. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  86. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  87. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  88. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  89. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  90. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  91. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  92. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  93. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
  94. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  96. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
  97. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  98. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
  99. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  100. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  101. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  102. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
  103. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
  104. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
  105. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  106. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  107. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  108. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  109. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  110. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  111. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  112. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  113. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  114. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  115. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  116. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  117. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  118. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  119. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  120. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  121. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  122. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  123. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  124. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  125. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  126. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  127. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  128. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  129. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  130. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  131. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  132. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  133. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  134. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  135. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  136. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  137. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  138. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  139. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  140. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  141. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  142. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  143. data/sig/rigor/scope.rbs +22 -0
  144. metadata +157 -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: 182bad9de02b3b4579fe1c385fa740e30f2df85cad36d8c21647dfb09004b9eb
4
+ data.tar.gz: 398d4ebc670530522696122117592bd59b60d7f899ae0a8cd58b11c8506ec608
5
5
  SHA512:
6
- metadata.gz: 749cd171f08f03311d70da91e742b07980974876889d152827ae8e3aa4518d2c2f933475efa889e3c63a7f715eddb96112584445967c3e15fe7c58c38d0ed3cd
7
- data.tar.gz: bb5f032e6663cb137b774bfd18d39ad1498b0913d552a92b86c0256fd8a2fb76c1426d9914f01caf3f78359c96079d8dfc7c84f09e2fb473681cf0889ee46e09
6
+ metadata.gz: abd25775ea4973023dc7ef7132669a0bf556604c7378caef60aa3c2fbae4ef79bc2651cd3499cecb8046507214dbdc4cb56bdb953c301ec42b0ebb86291202b6
7
+ data.tar.gz: 9281f16a4b2d39847aaaf408844920dfb9f2e6a914df3544182d6d3832f28f03ca63d8a19aa54ca80977f3431351717a59d7ba704f3361ee92697be21d6193ab
@@ -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
@@ -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
- return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
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
- return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
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)
@@ -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
@@ -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