rigortype 0.1.14 → 0.1.16

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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules.rb +428 -6
  5. data/lib/rigor/analysis/diagnostic.rb +55 -3
  6. data/lib/rigor/analysis/rule_catalog.rb +80 -0
  7. data/lib/rigor/analysis/runner.rb +71 -2
  8. data/lib/rigor/analysis/worker_session.rb +3 -2
  9. data/lib/rigor/cache/descriptor.rb +6 -2
  10. data/lib/rigor/cli/plugin_command.rb +245 -0
  11. data/lib/rigor/cli/plugins_command.rb +51 -4
  12. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  13. data/lib/rigor/cli.rb +143 -5
  14. data/lib/rigor/configuration/severity_profile.rb +9 -0
  15. data/lib/rigor/environment/rbs_loader.rb +259 -1
  16. data/lib/rigor/environment.rb +8 -2
  17. data/lib/rigor/inference/budget_trace.rb +137 -0
  18. data/lib/rigor/inference/expression_typer.rb +9 -2
  19. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  20. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  21. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
  22. data/lib/rigor/inference/method_dispatcher.rb +57 -10
  23. data/lib/rigor/inference/precision_scanner.rb +60 -1
  24. data/lib/rigor/inference/scope_indexer.rb +184 -27
  25. data/lib/rigor/inference/statement_evaluator.rb +13 -8
  26. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  27. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  28. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  29. data/lib/rigor/plugin/base.rb +321 -2
  30. data/lib/rigor/plugin/box.rb +64 -0
  31. data/lib/rigor/plugin/inflector.rb +121 -0
  32. data/lib/rigor/plugin/isolation.rb +191 -0
  33. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  34. data/lib/rigor/plugin/macro.rb +1 -0
  35. data/lib/rigor/plugin/manifest.rb +120 -23
  36. data/lib/rigor/plugin/node_context.rb +62 -0
  37. data/lib/rigor/plugin/registry.rb +10 -0
  38. data/lib/rigor/plugin.rb +3 -0
  39. data/lib/rigor/scope.rb +27 -1
  40. data/lib/rigor/sig_gen/generator.rb +2 -3
  41. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  42. data/lib/rigor/source/literals.rb +118 -0
  43. data/lib/rigor/source/node_walker.rb +26 -0
  44. data/lib/rigor/source.rb +1 -0
  45. data/lib/rigor/triage/catalogue.rb +71 -5
  46. data/lib/rigor/type/combinator.rb +6 -1
  47. data/lib/rigor/type/union.rb +65 -1
  48. data/lib/rigor/version.rb +1 -1
  49. data/lib/rigor.rb +1 -0
  50. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  51. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  52. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  53. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  54. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  55. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  56. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  57. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  58. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  62. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  63. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  66. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  67. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  68. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  69. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  70. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  71. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  72. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  73. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  74. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  75. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  76. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  77. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  78. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  79. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  80. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  81. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  82. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  83. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  84. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
  85. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  86. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  87. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  88. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  89. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  90. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  91. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  92. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  93. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  94. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  95. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  96. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  97. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  98. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  99. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  100. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  101. data/sig/rigor/plugin/base.rbs +58 -3
  102. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  103. data/sig/rigor/plugin/manifest.rbs +31 -1
  104. data/sig/rigor/scope.rbs +3 -0
  105. data/sig/rigor/source.rbs +12 -0
  106. data/sig/rigor.rbs +5 -0
  107. data/skills/rigor-plugin-author/SKILL.md +33 -9
  108. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +65 -26
  109. data/skills/rigor-plugin-author/references/02-walker-and-types.md +213 -80
  110. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  111. data/skills/rigor-project-init/SKILL.md +72 -7
  112. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +233 -19
  113. metadata +53 -2
  114. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
@@ -288,6 +288,86 @@ module Rigor
288
288
  since: "0.1.2"
289
289
  ),
290
290
 
291
+ CheckRules::RULE_OVERRIDE_VISIBILITY_REDUCED => Entry.new(
292
+ id: CheckRules::RULE_OVERRIDE_VISIBILITY_REDUCED,
293
+ summary: "Instance-method override reduces the visibility it inherits from an ancestor.",
294
+ fires_when: [
295
+ "An instance `def` shadows a same-name instance method defined by a project-discovered " \
296
+ "ancestor (included/prepended module or superclass, cross-file).",
297
+ "The override's source-discovered visibility is strictly more restrictive than the " \
298
+ "ancestor's (public → protected/private, or protected → private).",
299
+ "Both visibilities are statically observable from project source."
300
+ ],
301
+ does_not_fire_when: [
302
+ "Override raises or preserves visibility (only reductions break substitutability).",
303
+ "The shadowed method lives on an RBS-known / third-party ancestor (RBS models only " \
304
+ "public/private; RBS-parent visibility is a deferred follow-on).",
305
+ "`def self.foo` singleton methods (visibility is instance-side only).",
306
+ "The `private def foo; end` wrap-around form (not yet tracked by the visibility walker)."
307
+ ],
308
+ suppression: "`# rigor:disable def.override-visibility-reduced` on the override.",
309
+ severity_authored: :warning,
310
+ severity_by_profile: { lenient: :off, balanced: :warning, strict: :error },
311
+ since: "0.1.15"
312
+ ),
313
+
314
+ CheckRules::RULE_OVERRIDE_RETURN_WIDENED => Entry.new(
315
+ id: CheckRules::RULE_OVERRIDE_RETURN_WIDENED,
316
+ summary: "Instance-method override widens the return type it inherits from an ancestor.",
317
+ fires_when: [
318
+ "An instance `def` with an authored RBS signature overrides a same-name method whose " \
319
+ "RBS signature is declared by a project-discovered ancestor (module or superclass).",
320
+ "The override's declared return is not acceptable where the ancestor's declared return " \
321
+ "is expected (`parent_return.accepts(override_return)` is `:no`) — a covariance violation."
322
+ ],
323
+ does_not_fire_when: [
324
+ "Either side lacks an authored RBS signature (WD1 both-sides-authored gate).",
325
+ "The override narrows or preserves the return (covariant-safe).",
326
+ "The ancestor's return is `untyped` / `self` / an unbound generic (degrades to " \
327
+ "`Dynamic[Top]`, which accepts everything — FP-safe).",
328
+ "The subtype relationship between the two return types is not resolvable from loaded " \
329
+ "Ruby classes / their ancestors (a user-only class hierarchy degrades to `:maybe` and " \
330
+ "stays silent — the check has reach over core / stdlib / loadable-gem hierarchies).",
331
+ "`def self.foo` singleton methods (instance-side only in v1).",
332
+ "The shadowed method lives only on an RBS-known / third-party ancestor not in the " \
333
+ "project-discovered chain (user-source ancestor scope in v1)."
334
+ ],
335
+ suppression: "`# rigor:disable def.override-return-widened` on the override.",
336
+ severity_authored: :warning,
337
+ severity_by_profile: { lenient: :off, balanced: :warning, strict: :error },
338
+ since: "0.1.15"
339
+ ),
340
+
341
+ CheckRules::RULE_OVERRIDE_PARAM_NARROWED => Entry.new(
342
+ id: CheckRules::RULE_OVERRIDE_PARAM_NARROWED,
343
+ summary: "Instance-method override narrows a parameter type it inherits from an ancestor.",
344
+ fires_when: [
345
+ "An instance `def` with an authored RBS signature overrides a same-name method whose " \
346
+ "RBS signature is declared by a project-discovered ancestor (module or superclass).",
347
+ "At some matching positional parameter index, the override's slot cannot accept the " \
348
+ "ancestor's parameter type (`override_param.accepts(parent_param)` is `:no`) — a " \
349
+ "contravariance violation (the override narrowed the parameter)."
350
+ ],
351
+ does_not_fire_when: [
352
+ "Either side lacks an authored RBS signature (WD1 both-sides-authored gate).",
353
+ "The override widens or preserves the parameter (contravariant-safe).",
354
+ "Either side is overloaded (more than one method type — arm mapping is ambiguous).",
355
+ "The ancestor's parameter is `untyped` / an unbound generic / an interface (degrades " \
356
+ "to `Dynamic[Top]`, which is passable to anything — FP-safe).",
357
+ "The subtype relationship between the two parameter types is not resolvable from loaded " \
358
+ "Ruby classes / their ancestors (a user-only class hierarchy degrades to `:maybe` and " \
359
+ "stays silent — the check has reach over core / stdlib / loadable-gem hierarchies).",
360
+ "Arity / keyword-requiredness divergence (out of scope for v1 — positional types only).",
361
+ "`def self.foo` singleton methods (instance-side only in v1).",
362
+ "The shadowed method lives only on an RBS-known / third-party ancestor (user-source " \
363
+ "ancestor scope in v1)."
364
+ ],
365
+ suppression: "`# rigor:disable def.override-param-narrowed` on the override.",
366
+ severity_authored: :warning,
367
+ severity_by_profile: { lenient: :off, balanced: :warning, strict: :error },
368
+ since: "0.1.15"
369
+ ),
370
+
291
371
  CheckRules::RULE_IVAR_WRITE_MISMATCH => Entry.new(
292
372
  id: CheckRules::RULE_IVAR_WRITE_MISMATCH,
293
373
  summary: "Same instance variable assigned a different concrete class within one class.",
@@ -106,11 +106,15 @@ module Rigor
106
106
  # nil-guards.
107
107
  @class_decl_paths_snapshot = {}.freeze
108
108
  @signature_paths_snapshot = [].freeze
109
+ @synthesized_namespaces_snapshot = [].freeze
109
110
  @cached_plugin_prepare_diagnostics = [].freeze
110
111
  @project_discovered_classes = {}.freeze
111
112
  @project_discovered_def_nodes = {}.freeze
113
+ @project_discovered_def_sources = {}.freeze
112
114
  @project_discovered_superclasses = {}.freeze
113
115
  @project_discovered_includes = {}.freeze
116
+ @project_discovered_method_visibilities = {}.freeze
117
+ @project_discovered_methods = {}.freeze
114
118
  end
115
119
 
116
120
  # ADR-pending editor mode — present when the runner is wired
@@ -139,6 +143,7 @@ module Rigor
139
143
  expansion = expand_paths(paths)
140
144
  @class_decl_paths_snapshot = {}.freeze
141
145
  @signature_paths_snapshot = []
146
+ @synthesized_namespaces_snapshot = []
142
147
 
143
148
  if @prebuilt
144
149
  adopt_prebuilt_project_scan(@prebuilt)
@@ -148,6 +153,7 @@ module Rigor
148
153
 
149
154
  diagnostics = pre_file_diagnostics(expansion)
150
155
  diagnostics += analyze_files(target_files(expansion))
156
+ diagnostics += rbs_synthesized_namespace_diagnostics
151
157
  diagnostics += rbs_extended_reporter_diagnostics
152
158
  diagnostics += boundary_cross_diagnostics
153
159
  diagnostics += source_rbs_synthesis_diagnostics
@@ -257,8 +263,11 @@ module Rigor
257
263
  def_index =
258
264
  Inference::ScopeIndexer.discovered_def_index_for_paths(expansion.fetch(:files), buffer: @buffer)
259
265
  @project_discovered_def_nodes = def_index.fetch(:def_nodes)
266
+ @project_discovered_def_sources = def_index.fetch(:def_sources)
260
267
  @project_discovered_superclasses = def_index.fetch(:superclasses)
261
268
  @project_discovered_includes = def_index.fetch(:includes)
269
+ @project_discovered_method_visibilities = def_index.fetch(:method_visibilities)
270
+ @project_discovered_methods = def_index.fetch(:methods)
262
271
  end
263
272
 
264
273
  # Internal: adopts a frozen {ProjectScan} snapshot supplied
@@ -299,6 +308,16 @@ module Rigor
299
308
  dispatch_pool(files)
300
309
  else
301
310
  environment = resolve_sequential_environment(source_files: files)
311
+ # Snapshot the small synthesized-namespace name list (NOT the
312
+ # env — see the method comment) so #run can surface the
313
+ # malformed-RBS `:info` diagnostic without rebuilding the env.
314
+ # Gated on the project actually declaring `signature_paths:`:
315
+ # synthesis only matters for the project's own RBS, and
316
+ # `#synthesized_namespaces` forces the (otherwise-lazy) RBS env
317
+ # to build — doing so when there is no project sig set would
318
+ # warm `.rigor/cache` on a bare `--no-stats` run.
319
+ @synthesized_namespaces_snapshot =
320
+ project_signature_paths? ? (environment.rbs_loader&.synthesized_namespaces || []) : []
302
321
  result = files.flat_map { |path| analyze_file(path, environment) }
303
322
  if @collect_stats
304
323
  loader = environment.rbs_loader
@@ -1112,6 +1131,48 @@ module Rigor
1112
1131
  [build_rbs_coverage_missing_diagnostic(missing)]
1113
1132
  end
1114
1133
 
1134
+ # Robustness uplift companion (ADR-5) — when the project's
1135
+ # `signature_paths:` RBS declared qualified names without their
1136
+ # enclosing namespace, `RbsLoader` synthesizes the missing
1137
+ # `module`s so the otherwise-inert signatures resolve. Surface a
1138
+ # single `:info` diagnostic naming them so the user knows their
1139
+ # sig set is malformed (`rbs validate` rejects it) and can fix it
1140
+ # at the source. Authored `:info`: the analysis already succeeded;
1141
+ # this is advisory, never a gate. Empty for a well-formed sig set.
1142
+ def rbs_synthesized_namespace_diagnostics
1143
+ synthesized = @synthesized_namespaces_snapshot
1144
+ return [] if synthesized.nil? || synthesized.empty?
1145
+
1146
+ [build_rbs_synthesized_namespace_diagnostic(synthesized)]
1147
+ end
1148
+
1149
+ # True when the project declares its own `signature_paths:` (the
1150
+ # only place the qualified-name-without-namespace mistake lives).
1151
+ def project_signature_paths?
1152
+ paths = @configuration.signature_paths
1153
+ !(paths.nil? || paths.empty?)
1154
+ end
1155
+
1156
+ def build_rbs_synthesized_namespace_diagnostic(synthesized)
1157
+ sample_size = 5
1158
+ sample = synthesized.first(sample_size)
1159
+ suffix = synthesized.size > sample_size ? ", and #{synthesized.size - sample_size} more" : ""
1160
+ Diagnostic.new(
1161
+ path: ".rigor.yml",
1162
+ line: 1,
1163
+ column: 1,
1164
+ message: "#{synthesized.size} RBS namespace(s) under `signature_paths:` are " \
1165
+ "referenced by qualified declarations (e.g. `class Foo::Bar`) but never " \
1166
+ "declared: #{sample.join(', ')}#{suffix}. `rbs validate` rejects this; " \
1167
+ "Rigor synthesized the missing `module`(s) so the signatures still " \
1168
+ "resolve. Declare each (`module <name>` / `class <name>`) in your RBS to " \
1169
+ "make the sig set valid upstream.",
1170
+ severity: :info,
1171
+ rule: "rbs.coverage.synthesized-namespace",
1172
+ source_family: :builtin
1173
+ )
1174
+ end
1175
+
1115
1176
  def build_rbs_coverage_missing_diagnostic(missing)
1116
1177
  sample_size = 5
1117
1178
  sample = missing.first(sample_size).map(&:gem_name)
@@ -1324,8 +1385,9 @@ module Rigor
1324
1385
  end
1325
1386
 
1326
1387
  def collect_plugin_diagnostics(plugin, path, root, scope)
1327
- raw = plugin.diagnostics_for_file(path: path, scope: scope, root: root)
1328
- Array(raw).map { |diagnostic| stamp_plugin_diagnostic(diagnostic, plugin.manifest.id) }
1388
+ raw = Array(plugin.diagnostics_for_file(path: path, scope: scope, root: root))
1389
+ raw += plugin.node_rule_diagnostics(path: path, scope: scope, root: root)
1390
+ raw.map { |diagnostic| stamp_plugin_diagnostic(diagnostic, plugin.manifest.id) }
1329
1391
  rescue StandardError => e
1330
1392
  [plugin_runtime_error_diagnostic(path, plugin, e)]
1331
1393
  end
@@ -1449,10 +1511,17 @@ module Rigor
1449
1511
  unless @project_discovered_def_nodes.empty?
1450
1512
  scope = scope.with_discovered_def_nodes(@project_discovered_def_nodes)
1451
1513
  end
1514
+ unless @project_discovered_def_sources.empty?
1515
+ scope = scope.with_discovered_def_sources(@project_discovered_def_sources)
1516
+ end
1452
1517
  unless @project_discovered_superclasses.empty?
1453
1518
  scope = scope.with_discovered_superclasses(@project_discovered_superclasses)
1454
1519
  end
1455
1520
  scope = scope.with_discovered_includes(@project_discovered_includes) unless @project_discovered_includes.empty?
1521
+ unless @project_discovered_method_visibilities.empty?
1522
+ scope = scope.with_discovered_method_visibilities(@project_discovered_method_visibilities)
1523
+ end
1524
+ scope = scope.with_discovered_methods(@project_discovered_methods) unless @project_discovered_methods.empty?
1456
1525
  scope
1457
1526
  end
1458
1527
 
@@ -284,8 +284,9 @@ module Rigor
284
284
  end
285
285
 
286
286
  def collect_plugin_diagnostics(plugin, path, root, scope)
287
- raw = plugin.diagnostics_for_file(path: path, scope: scope, root: root)
288
- Array(raw).map { |diagnostic| stamp_plugin_diagnostic(diagnostic, plugin.manifest.id) }
287
+ raw = Array(plugin.diagnostics_for_file(path: path, scope: scope, root: root))
288
+ raw += plugin.node_rule_diagnostics(path: path, scope: scope, root: root)
289
+ raw.map { |diagnostic| stamp_plugin_diagnostic(diagnostic, plugin.manifest.id) }
289
290
  rescue StandardError => e
290
291
  [plugin_runtime_error_diagnostic(path, plugin, e)]
291
292
  end
@@ -26,8 +26,12 @@ module Rigor
26
26
  # mixes this into the cache key, so a bump implicitly
27
27
  # invalidates every cached value. v2 added the
28
28
  # `dependencies` slot for ADR-10 per-gem-version cache slice
29
- # invalidation.
30
- SCHEMA_VERSION = 2
29
+ # invalidation. v3: `RbsLoader.build_env_for` now synthesizes
30
+ # `module`s for namespaces a project's `signature_paths:` RBS
31
+ # references but never declares, so the marshalled RBS env
32
+ # cached by an older Rigor (which would leave those signatures
33
+ # inert) MUST be rebuilt for the synthesis to take effect.
34
+ SCHEMA_VERSION = 3
31
35
 
32
36
  # Per-slot entry value objects. Constructors validate enums /
33
37
  # required fields and freeze the resulting struct so no caller
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class CLI
5
+ # `rigor plugin` (singular) — discover and read the plugin source
6
+ # bundled with the `rigortype` gem.
7
+ #
8
+ # Rigor ships ~30 production plugins under `plugins/` and a set of
9
+ # tutorial plugins under `examples/`. When Rigor is installed via
10
+ # `mise` / `gem install` the gem checkout is on disk, so a plugin
11
+ # author (or an AI coding agent following the `rigor-plugin-author`
12
+ # skill) can read a real, working plugin as a worked example —
13
+ # instead of guessing the `Rigor::Plugin::Base` surface from prose.
14
+ # This command outputs the absolute paths so they can be found and
15
+ # read regardless of where the gem landed.
16
+ #
17
+ # It is deliberately distinct from `rigor plugins` (plural), which
18
+ # reports the activation status of the plugins configured in *your*
19
+ # `.rigor.yml`. This command (singular) browses the plugins bundled
20
+ # in the *toolchain*. Mnemonic: "plugins" = my config; "plugin" =
21
+ # the catalogue I can learn from.
22
+ #
23
+ # Subcommands:
24
+ #
25
+ # - `rigor plugin list` — every bundled plugin + example,
26
+ # name + absolute directory path.
27
+ # - `rigor plugin path <name>` — one-line absolute path to the
28
+ # plugin's directory (Read-tool input).
29
+ # - `rigor plugin print <name>` — a header (dir / lib / sig / README
30
+ # paths) followed by the plugin's main
31
+ # `lib/<name>.rb` source body.
32
+ # - `rigor plugin root` — the rigortype gem root and its key
33
+ # subdirectories (lib/, plugins/,
34
+ # examples/, skills/, sig/), so an
35
+ # author can read the public plugin
36
+ # API (`lib/rigor/plugin.rb`) directly.
37
+ #
38
+ # `rigor plugin` with no subcommand is an alias for `list`.
39
+ #
40
+ # **Docker / cross-filesystem note.** Every path printed is resolved
41
+ # at runtime from this file's location, so it is correct *on the
42
+ # filesystem where `rigor` runs*. If you run `rigor` inside a
43
+ # container but read files from the host (or vice versa), the paths
44
+ # will not resolve — read them from the same environment that ran
45
+ # the command (`rigor plugin print` inlines the body for exactly
46
+ # this case: it works with no file-reading tool at all).
47
+ class PluginCommand
48
+ USAGE = <<~USAGE
49
+ Usage: rigor plugin <subcommand> [args]
50
+
51
+ Browse the plugins bundled in the rigortype toolchain (worked
52
+ examples for authoring your own). For the activation status of
53
+ the plugins in your .rigor.yml, use `rigor plugins` (plural).
54
+
55
+ Subcommands:
56
+ list List bundled + example plugins (default)
57
+ path <name> Print the absolute directory path of <name>
58
+ print <name> Print <name>'s main lib source, with a header
59
+ root Print the gem root + key subdirectories
60
+
61
+ Examples:
62
+ rigor plugin list
63
+ rigor plugin path rigor-activerecord
64
+ rigor plugin print rigor-activesupport-core-ext
65
+ rigor plugin root
66
+ USAGE
67
+
68
+ # The bundled plugins/examples/source live at `<gem_root>/...`.
69
+ # From `lib/rigor/cli/plugin_command.rb` the gem root is three
70
+ # directories up (matching SkillCommand::SKILLS_ROOT).
71
+ GEM_ROOT = File.expand_path("../../..", __dir__)
72
+ PLUGINS_ROOT = File.join(GEM_ROOT, "plugins")
73
+ EXAMPLES_ROOT = File.join(GEM_ROOT, "examples")
74
+
75
+ def initialize(argv:, out: $stdout, err: $stderr)
76
+ @argv = argv
77
+ @out = out
78
+ @err = err
79
+ end
80
+
81
+ # @return [Integer] CLI exit status.
82
+ def run
83
+ subcommand = @argv.shift || "list"
84
+
85
+ case subcommand
86
+ when "list" then run_list
87
+ when "path" then run_path
88
+ when "print" then run_print
89
+ when "root" then run_root
90
+ when "-h", "--help", "help"
91
+ @out.puts(USAGE)
92
+ 0
93
+ else
94
+ @err.puts("Unknown subcommand: #{subcommand}")
95
+ @err.puts(USAGE)
96
+ Rigor::CLI::EXIT_USAGE
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ def run_list
103
+ plugins = discover(PLUGINS_ROOT)
104
+ examples = discover(EXAMPLES_ROOT)
105
+ if plugins.empty? && examples.empty?
106
+ @err.puts("No bundled plugins found under #{PLUGINS_ROOT}")
107
+ return 1
108
+ end
109
+
110
+ width = (plugins + examples).map { |p| p.fetch(:name).length }.max
111
+ emit_group("Production plugins", PLUGINS_ROOT, plugins, width)
112
+ emit_group("Example plugins (tutorials)", EXAMPLES_ROOT, examples, width)
113
+ @out.puts
114
+ @out.puts("Engine source root: #{GEM_ROOT}")
115
+ @out.puts(" public plugin API: #{File.join(GEM_ROOT, 'lib/rigor/plugin.rb')}")
116
+ @out.puts(docker_note)
117
+ 0
118
+ end
119
+
120
+ def emit_group(label, root, entries, width)
121
+ return if entries.empty?
122
+
123
+ @out.puts("#{label} — under #{root}:")
124
+ entries.each do |entry|
125
+ @out.puts(format(" %-#{width}s %s", entry.fetch(:name), entry.fetch(:path)))
126
+ end
127
+ end
128
+
129
+ def run_path
130
+ name = @argv.shift
131
+ return usage_error("`path` requires a plugin name") if name.nil?
132
+
133
+ plugin = find(name)
134
+ return name_error(name) if plugin.nil?
135
+
136
+ @out.puts(plugin.fetch(:path))
137
+ 0
138
+ end
139
+
140
+ def run_print
141
+ name = @argv.shift
142
+ return usage_error("`print` requires a plugin name") if name.nil?
143
+
144
+ plugin = find(name)
145
+ return name_error(name) if plugin.nil?
146
+
147
+ @out.puts(render_print_header(plugin))
148
+ @out.puts
149
+ entry = main_source_file(plugin)
150
+ if entry
151
+ @out.write(File.read(entry))
152
+ else
153
+ @out.puts("# (no lib/#{plugin.fetch(:name)}.rb entry file found; browse the directory above)")
154
+ end
155
+ 0
156
+ end
157
+
158
+ def run_root
159
+ @out.puts("rigortype gem root: #{GEM_ROOT}")
160
+ {
161
+ "lib (engine source)" => File.join(GEM_ROOT, "lib"),
162
+ "lib/rigor/plugin.rb (public plugin API)" => File.join(GEM_ROOT, "lib/rigor/plugin.rb"),
163
+ "plugins (production plugins)" => PLUGINS_ROOT,
164
+ "examples (tutorial plugins)" => EXAMPLES_ROOT,
165
+ "skills (Agent Skills)" => File.join(GEM_ROOT, "skills"),
166
+ "sig (bundled RBS)" => File.join(GEM_ROOT, "sig")
167
+ }.each do |label, path|
168
+ marker = File.exist?(path) ? "" : " (missing)"
169
+ @out.puts(format(" %<label>-42s %<path>s%<marker>s", label: label, path: path, marker: marker))
170
+ end
171
+ @out.puts(docker_note)
172
+ 0
173
+ end
174
+
175
+ # The header that precedes the plugin body when an author runs
176
+ # `rigor plugin print <name>`. `# `-prefixed so the combined
177
+ # output stays readable; the Ruby body below it is unchanged.
178
+ def render_print_header(plugin)
179
+ dir = plugin.fetch(:path)
180
+ sig = File.join(dir, "sig")
181
+ readme = File.join(dir, "README.md")
182
+ <<~HEADER.chomp
183
+ # Rigor plugin: #{plugin.fetch(:name)} (#{plugin.fetch(:kind)})
184
+ # Directory: #{dir}
185
+ # Lib: #{File.join(dir, 'lib')}
186
+ # Sig: #{File.directory?(sig) ? sig : '(none)'}
187
+ # README: #{File.file?(readme) ? readme : '(none)'}
188
+ #
189
+ # A real, working plugin shipped with rigortype #{Rigor::VERSION}.
190
+ # The main source body is below; read the other files from the
191
+ # paths above. To suppress `call.undefined-method` for methods a
192
+ # DSL generates, study how an RBS-bundle plugin ships `sig/` (see
193
+ # `rigor plugin print rigor-activesupport-core-ext`).
194
+ HEADER
195
+ end
196
+
197
+ def discover(root)
198
+ return [] unless File.directory?(root)
199
+
200
+ kind = root == EXAMPLES_ROOT ? "example" : "production"
201
+ Dir.children(root).sort.filter_map do |name|
202
+ dir = File.join(root, name)
203
+ next unless File.directory?(dir)
204
+
205
+ { name: name, path: dir, kind: kind }
206
+ end
207
+ end
208
+
209
+ # Match the directory name with or without the conventional
210
+ # `rigor-` prefix, so both `rigor-activerecord` and
211
+ # `activerecord` resolve.
212
+ def find(name)
213
+ all = discover(PLUGINS_ROOT) + discover(EXAMPLES_ROOT)
214
+ all.find { |p| p.fetch(:name) == name } ||
215
+ all.find { |p| p.fetch(:name).delete_prefix("rigor-") == name.delete_prefix("rigor-") }
216
+ end
217
+
218
+ def main_source_file(plugin)
219
+ dir = plugin.fetch(:path)
220
+ candidate = File.join(dir, "lib", "#{plugin.fetch(:name)}.rb")
221
+ return candidate if File.file?(candidate)
222
+
223
+ Dir.glob(File.join(dir, "lib", "*.rb")).min
224
+ end
225
+
226
+ def docker_note
227
+ "\nNote: paths are local to where `rigor` runs. If you run rigor in a " \
228
+ "container,\nread these files from the same filesystem (use " \
229
+ "`rigor plugin print` to inline a body)."
230
+ end
231
+
232
+ def name_error(name)
233
+ @err.puts("Unknown plugin: #{name}")
234
+ @err.puts("Run `rigor plugin list` to see the bundled plugins.")
235
+ 1
236
+ end
237
+
238
+ def usage_error(message)
239
+ @err.puts(message)
240
+ @err.puts(USAGE)
241
+ Rigor::CLI::EXIT_USAGE
242
+ end
243
+ end
244
+ end
245
+ end
@@ -32,7 +32,15 @@ module Rigor
32
32
  # `trait_registries:` / `external_files:` /
33
33
  # `type_node_resolvers:` / `hkt_registrations:` /
34
34
  # `hkt_definitions:` / `protocol_contracts:` /
35
- # `source_rbs_synthesizer:`).
35
+ # `source_rbs_synthesizer:`);
36
+ # - the ADR-37 narrow extension protocols read off the plugin
37
+ # class — `node_rule` node types, `dynamic_return` receivers,
38
+ # `type_specifier` methods.
39
+ #
40
+ # `--capabilities` switches to a focused catalogue of just the
41
+ # narrow-protocol gate values + produced/consumed facts (ADR-37
42
+ # § "Machine-readable capability catalogue") — the AI-legibility
43
+ # surface that lets an agent enumerate what every plugin does.
36
44
  #
37
45
  # Output formats: `text` (default, human-readable table) and
38
46
  # `json` (for tooling — SKILLs, CI gates, editor integrations).
@@ -52,7 +60,7 @@ module Rigor
52
60
  # the RBS environment without conflict (requires constructing
53
61
  # the Environment, which is heavier than the loader-only
54
62
  # pass this slice does).
55
- class PluginsCommand
63
+ class PluginsCommand # rubocop:disable Metrics/ClassLength
56
64
  USAGE = "Usage: rigor plugins [options]"
57
65
 
58
66
  def initialize(argv:, out: $stdout, err: $stderr)
@@ -69,7 +77,7 @@ module Rigor
69
77
  rows = build_rows(configuration)
70
78
 
71
79
  renderer = PluginsRenderer.new(rows: rows, configuration_path: config_path)
72
- @out.puts(options.fetch(:format) == "json" ? renderer.json : renderer.text)
80
+ @out.puts(render(renderer, options))
73
81
 
74
82
  any_load_errors = rows.any? { |row| row.fetch(:status) == :load_error }
75
83
  return 1 if any_load_errors && options.fetch(:strict)
@@ -79,13 +87,32 @@ module Rigor
79
87
 
80
88
  private
81
89
 
90
+ # Picks the renderer view. `--capabilities` switches to the
91
+ # focused extension-protocol catalogue (ADR-37 § "Machine-readable
92
+ # capability catalogue") — per plugin, only the gate values that
93
+ # tell a reader (or an AI agent) exactly what the plugin
94
+ # contributes: the node-rule node types, the dynamic-return
95
+ # receivers, the type-specifier methods, and the produced /
96
+ # consumed facts. The default view stays the full activation report.
97
+ def render(renderer, options)
98
+ json = options.fetch(:format) == "json"
99
+ if options.fetch(:capabilities)
100
+ json ? renderer.capabilities_json : renderer.capabilities_text
101
+ else
102
+ json ? renderer.json : renderer.text
103
+ end
104
+ end
105
+
82
106
  def parse_options
83
- options = { config: nil, format: "text", strict: false }
107
+ options = { config: nil, format: "text", strict: false, capabilities: false }
84
108
  OptionParser.new do |opts|
85
109
  opts.banner = USAGE
86
110
  opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
87
111
  opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
88
112
  opts.on("--strict", "Exit 1 if any plugin failed to load (CI gate)") { options[:strict] = true }
113
+ opts.on("--capabilities", "Emit the per-plugin extension-protocol catalogue (ADR-37)") do
114
+ options[:capabilities] = true
115
+ end
89
116
  end.parse!(@argv)
90
117
  validate!(options)
91
118
  options
@@ -165,9 +192,25 @@ module Rigor
165
192
  manifest = plugin.manifest
166
193
  identity_fields(gem_name, manifest, config)
167
194
  .merge(extension_fields(plugin, manifest))
195
+ .merge(narrow_protocol_fields(plugin))
168
196
  .merge(load_error: nil)
169
197
  end
170
198
 
199
+ # ADR-37 narrow extension protocols. Unlike the 10 declarative
200
+ # manifest fields, these are class-level DSLs (`node_rule` /
201
+ # `dynamic_return` / `type_specifier`), so they are read off the
202
+ # plugin class rather than the manifest. The gate values — node
203
+ # types, receiver class names, specified method names — are the
204
+ # greppable, enumerable surface the capability catalogue exposes.
205
+ def narrow_protocol_fields(plugin)
206
+ klass = plugin.class
207
+ {
208
+ node_rule_types: klass.node_rules.map { |r| r[:node_type].name }.uniq,
209
+ dynamic_return_receivers: klass.dynamic_returns.flat_map { |r| r[:receivers] }.uniq,
210
+ type_specifier_methods: klass.type_specifiers.flat_map { |r| r[:methods] }.map(&:to_s).uniq
211
+ }
212
+ end
213
+
171
214
  def identity_fields(gem_name, manifest, config)
172
215
  {
173
216
  gem: gem_name,
@@ -225,6 +268,9 @@ module Rigor
225
268
  hkt_definitions: 0,
226
269
  protocol_contracts: 0,
227
270
  source_rbs_synthesizer: false,
271
+ node_rule_types: [],
272
+ dynamic_return_receivers: [],
273
+ type_specifier_methods: [],
228
274
  load_error: error&.message || "plugin did not register or could not be matched to a registered class"
229
275
  }
230
276
  end
@@ -299,6 +345,7 @@ module Rigor
299
345
  external_files: 0, type_node_resolvers: 0,
300
346
  hkt_registrations: 0, hkt_definitions: 0,
301
347
  protocol_contracts: 0, source_rbs_synthesizer: false,
348
+ node_rule_types: [], dynamic_return_receivers: [], type_specifier_methods: [],
302
349
  load_error: error.message
303
350
  }
304
351
  end