rigortype 0.1.15 → 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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules.rb +25 -1
  5. data/lib/rigor/analysis/diagnostic.rb +40 -0
  6. data/lib/rigor/analysis/runner.rb +61 -2
  7. data/lib/rigor/analysis/worker_session.rb +3 -2
  8. data/lib/rigor/cache/descriptor.rb +6 -2
  9. data/lib/rigor/cli/plugins_command.rb +51 -4
  10. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  11. data/lib/rigor/cli.rb +135 -5
  12. data/lib/rigor/environment/rbs_loader.rb +259 -1
  13. data/lib/rigor/environment.rb +8 -2
  14. data/lib/rigor/inference/budget_trace.rb +137 -0
  15. data/lib/rigor/inference/expression_typer.rb +9 -2
  16. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  17. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  18. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
  19. data/lib/rigor/inference/method_dispatcher.rb +57 -10
  20. data/lib/rigor/inference/precision_scanner.rb +60 -1
  21. data/lib/rigor/inference/scope_indexer.rb +127 -8
  22. data/lib/rigor/inference/statement_evaluator.rb +13 -8
  23. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  24. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  25. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  26. data/lib/rigor/plugin/base.rb +321 -2
  27. data/lib/rigor/plugin/box.rb +64 -0
  28. data/lib/rigor/plugin/inflector.rb +121 -0
  29. data/lib/rigor/plugin/isolation.rb +191 -0
  30. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  31. data/lib/rigor/plugin/macro.rb +1 -0
  32. data/lib/rigor/plugin/manifest.rb +120 -23
  33. data/lib/rigor/plugin/node_context.rb +62 -0
  34. data/lib/rigor/plugin/registry.rb +10 -0
  35. data/lib/rigor/plugin.rb +3 -0
  36. data/lib/rigor/sig_gen/generator.rb +2 -3
  37. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  38. data/lib/rigor/source/literals.rb +118 -0
  39. data/lib/rigor/source/node_walker.rb +26 -0
  40. data/lib/rigor/source.rb +1 -0
  41. data/lib/rigor/type/combinator.rb +6 -1
  42. data/lib/rigor/type/union.rb +65 -1
  43. data/lib/rigor/version.rb +1 -1
  44. data/lib/rigor.rb +1 -0
  45. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  46. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  47. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  48. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  49. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  50. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  51. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  54. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  58. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  59. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  60. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  62. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  63. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  64. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  65. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  66. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  67. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  68. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  69. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  70. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  71. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  72. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  73. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  74. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  75. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  76. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  77. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  78. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  79. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
  80. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  81. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  82. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  83. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  84. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  85. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  86. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  87. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  88. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  89. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  90. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  91. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  92. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  93. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  94. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  95. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  96. data/sig/rigor/plugin/base.rbs +58 -3
  97. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  98. data/sig/rigor/plugin/manifest.rbs +31 -1
  99. data/sig/rigor/source.rbs +12 -0
  100. data/sig/rigor.rbs +5 -0
  101. data/skills/rigor-plugin-author/SKILL.md +13 -9
  102. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
  103. data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
  104. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  105. metadata +52 -2
  106. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 231749c95c76ed2647fb26973b926e0330953a1eb818b2f363acfbd241cab418
4
- data.tar.gz: 46a2f8dd756c64c1f49996d5a84716c2089f7af9d3f8d4f0e1af079fad72da54
3
+ metadata.gz: 2fb4015697adf7cbc9d65b2ca6ff954c8cac5f784ffb9616153d4af8d2505ccc
4
+ data.tar.gz: 52ee5acb7d233c0e86c8cdca45151c5f745b4c13d7155fcb15fafdaaed1d6dc9
5
5
  SHA512:
6
- metadata.gz: bfd342d93895c61d6afbac3323ea7e7b79648ca0fec370e05c68b864769107ef2dbe0c9540ea0410c73499b7fdde3187166edcb01158ce9c9991a114ff63ffa8
7
- data.tar.gz: 5a5b01c1fb25acc2f2fb416e5e4eb7ceb9dd15a90975ad7851aa6183c28716059d5331c58b95b8ec09936db43275118934ebbea84d64d443d35b0296e9dcf906
6
+ metadata.gz: 4f4b64afe3dbca26f6224762a2f1a3dd0bd4cade2ea9a61a30e2fba28ed29d3bb6d68bdfa73fb450536e5c7fef45e07a10a6fd0cec693ad228a828ff30905f8e
7
+ data.tar.gz: 4b5faa0d8c7295067f5a0f3d335c1800f22659e47b0428942405b07c1d38649163090f3a461e942a82aecaab6172e774ef88ca44f9a569b1392cdeee0be04086
data/README.md CHANGED
@@ -265,10 +265,12 @@ In-source suppression: `# rigor:disable <rule>` silences a single line;
265
265
 
266
266
  ## Status
267
267
 
268
- Current released version: **`v0.1.8`** (2026-05-21). The analyzer is
268
+ Current released version: **`v0.1.15`** (2026-05-29). The analyzer is
269
269
  usable on real Ruby code today; the rule catalogue is deliberately
270
270
  conservative — Rigor's stance is to surface zero false positives while
271
- the inference surface stabilises.
271
+ the inference surface stabilises. The `0.1.x` preview line has been
272
+ hardened against real OSS Rails codebases (Mastodon / Redmine / GitLab
273
+ FOSS); `v0.2.0` will open the first evaluation release.
272
274
 
273
275
  Release history: [`CHANGELOG.md`](CHANGELOG.md). Forward-looking
274
276
  commitments: [Roadmap](https://rigor.typedduck.fail/reference/roadmap/).
data/exe/rigor CHANGED
@@ -1,6 +1,25 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ # ADR-39 slice 5 — the `ruby_box` plugin-isolation strategy needs the
5
+ # experimental `Ruby::Box` feature, which is a process-start flag
6
+ # (`RUBY_BOX=1`, not toggleable at runtime). When a project selects that
7
+ # strategy (`RIGOR_PLUGIN_ISOLATION=ruby_box`, or the legacy `RIGOR_BOX`
8
+ # alias) we re-exec the same command with the flag set before anything
9
+ # else loads. The `RUBY_BOX` guard prevents an infinite re-exec, and the
10
+ # inherited environment (RUBYOPT / BUNDLE_*) preserves the Bundler
11
+ # context across the exec. The `none` (default) and `process` strategies
12
+ # need no flag, so this is a no-op for them — behaviour is unchanged
13
+ # unless a project opts into `ruby_box`.
14
+ rigor_isolation = ENV["RIGOR_PLUGIN_ISOLATION"].to_s
15
+ rigor_wants_box = rigor_isolation == "ruby_box" || !ENV["RIGOR_BOX"].to_s.empty?
16
+ if rigor_wants_box && ENV["RUBY_BOX"].to_s.empty?
17
+ require "rbconfig"
18
+ ENV["RUBY_BOX"] = "1"
19
+ ENV["RIGOR_PLUGIN_ISOLATION"] = "ruby_box" if rigor_isolation.empty?
20
+ exec(RbConfig.ruby, __FILE__, *ARGV)
21
+ end
22
+
4
23
  lib = File.expand_path("../lib", __dir__)
5
24
  $LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
6
25
 
@@ -383,7 +383,16 @@ module Rigor
383
383
  # its model). Flagging an undefined method on a class
384
384
  # with an open dynamic surface is unsound, so the rule
385
385
  # skips it.
386
- return nil if open_receiver?(class_name, scope)
386
+ # An unbounded receiver surface: either a plugin-declared
387
+ # open receiver (ADR-26 — e.g. `ActiveRecord::Relation`), or
388
+ # a type Rigor synthesized (a missing-namespace module / a
389
+ # stub for a referenced-but-undeclared type) to keep a
390
+ # malformed project signature buildable. A synthesized stub's
391
+ # method table is empty only because Rigor invented it, not
392
+ # because the real type is empty (the real `DRb` has
393
+ # `start_service`), so enumerating it to prove a call
394
+ # "undefined" would be a false positive.
395
+ return nil if unbounded_receiver_surface?(class_name, scope)
387
396
 
388
397
  # Slice 7 phase 12 — suppress when the user has
389
398
  # declared the method in source (`def` /
@@ -566,6 +575,14 @@ module Rigor
566
575
  # loaded plugin (manifest `open_receivers:`). An open
567
576
  # class responds beyond its RBS surface, so the
568
577
  # `call.undefined-method` rule must not fire for it.
578
+ # True when the receiver class responds beyond an enumerable
579
+ # RBS method table, so proving a call "undefined" against it is
580
+ # unsound: a plugin-declared open receiver, or a Rigor-
581
+ # synthesized stub type (see `RbsLoader#synthesized_type_names`).
582
+ def unbounded_receiver_surface?(class_name, scope)
583
+ open_receiver?(class_name, scope) || synthesized_stub_receiver?(class_name, scope)
584
+ end
585
+
569
586
  def open_receiver?(class_name, scope)
570
587
  registry = scope.environment&.plugin_registry
571
588
  return false if registry.nil?
@@ -573,6 +590,13 @@ module Rigor
573
590
  registry.open_receiver?(class_name)
574
591
  end
575
592
 
593
+ def synthesized_stub_receiver?(class_name, scope)
594
+ loader = scope.environment&.rbs_loader
595
+ return false if loader.nil? || !loader.respond_to?(:synthesized_type_names)
596
+
597
+ loader.synthesized_type_names.include?(class_name.to_s.sub(/\A::/, ""))
598
+ end
599
+
576
600
  def definition_available?(receiver_type, class_name, scope)
577
601
  if receiver_type.is_a?(Type::Singleton)
578
602
  !Rigor::Reflection.singleton_definition(class_name, scope: scope).nil?
@@ -59,6 +59,46 @@ module Rigor
59
59
  @project_definition_site = project_definition_site
60
60
  end
61
61
 
62
+ # Builds a Diagnostic positioned at a Prism node. Internalises
63
+ # the load-bearing convention every caller otherwise repeats:
64
+ # the line is the node's 1-based `start_line` and the column is
65
+ # `start_column + 1` (Prism columns are 0-based; Rigor reports
66
+ # 1-based). Pass any node responding to `#location`; all other
67
+ # fields forward to `#initialize` unchanged.
68
+ #
69
+ # `Plugin::Base#diagnostic` wraps this for plugin authors (who
70
+ # must not set `source_family` — the runner stamps it); core
71
+ # rules and other producers call it directly.
72
+ def self.from_node(node, path:, message:, severity: :error, rule: nil, # rubocop:disable Metrics/ParameterLists
73
+ source_family: DEFAULT_SOURCE_FAMILY,
74
+ receiver_type: nil, method_name: nil, project_definition_site: nil)
75
+ from_location(
76
+ node.location, path: path, message: message, severity: severity, rule: rule,
77
+ source_family: source_family, receiver_type: receiver_type,
78
+ method_name: method_name, project_definition_site: project_definition_site
79
+ )
80
+ end
81
+
82
+ # Builds a Diagnostic from an explicit Prism location, applying
83
+ # the same 1-based `line` / `start_column + 1` convention as
84
+ # {.from_node}. Use this when the diagnostic should point at a
85
+ # *sub-location* rather than the whole node — most often a call's
86
+ # `message_loc` (the matcher / method name) instead of the
87
+ # receiver-spanning `node.location`. {.from_node} is sugar for
88
+ # `from_location(node.location, …)`.
89
+ def self.from_location(location, path:, message:, severity: :error, rule: nil, # rubocop:disable Metrics/ParameterLists
90
+ source_family: DEFAULT_SOURCE_FAMILY,
91
+ receiver_type: nil, method_name: nil, project_definition_site: nil)
92
+ new(
93
+ path: path,
94
+ line: location.start_line,
95
+ column: location.start_column + 1,
96
+ message: message, severity: severity, rule: rule, source_family: source_family,
97
+ receiver_type: receiver_type, method_name: method_name,
98
+ project_definition_site: project_definition_site
99
+ )
100
+ end
101
+
62
102
  def error?
63
103
  severity == :error
64
104
  end
@@ -106,6 +106,7 @@ 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,6 +114,7 @@ module Rigor
113
114
  @project_discovered_superclasses = {}.freeze
114
115
  @project_discovered_includes = {}.freeze
115
116
  @project_discovered_method_visibilities = {}.freeze
117
+ @project_discovered_methods = {}.freeze
116
118
  end
117
119
 
118
120
  # ADR-pending editor mode — present when the runner is wired
@@ -141,6 +143,7 @@ module Rigor
141
143
  expansion = expand_paths(paths)
142
144
  @class_decl_paths_snapshot = {}.freeze
143
145
  @signature_paths_snapshot = []
146
+ @synthesized_namespaces_snapshot = []
144
147
 
145
148
  if @prebuilt
146
149
  adopt_prebuilt_project_scan(@prebuilt)
@@ -150,6 +153,7 @@ module Rigor
150
153
 
151
154
  diagnostics = pre_file_diagnostics(expansion)
152
155
  diagnostics += analyze_files(target_files(expansion))
156
+ diagnostics += rbs_synthesized_namespace_diagnostics
153
157
  diagnostics += rbs_extended_reporter_diagnostics
154
158
  diagnostics += boundary_cross_diagnostics
155
159
  diagnostics += source_rbs_synthesis_diagnostics
@@ -263,6 +267,7 @@ module Rigor
263
267
  @project_discovered_superclasses = def_index.fetch(:superclasses)
264
268
  @project_discovered_includes = def_index.fetch(:includes)
265
269
  @project_discovered_method_visibilities = def_index.fetch(:method_visibilities)
270
+ @project_discovered_methods = def_index.fetch(:methods)
266
271
  end
267
272
 
268
273
  # Internal: adopts a frozen {ProjectScan} snapshot supplied
@@ -303,6 +308,16 @@ module Rigor
303
308
  dispatch_pool(files)
304
309
  else
305
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 || []) : []
306
321
  result = files.flat_map { |path| analyze_file(path, environment) }
307
322
  if @collect_stats
308
323
  loader = environment.rbs_loader
@@ -1116,6 +1131,48 @@ module Rigor
1116
1131
  [build_rbs_coverage_missing_diagnostic(missing)]
1117
1132
  end
1118
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
+
1119
1176
  def build_rbs_coverage_missing_diagnostic(missing)
1120
1177
  sample_size = 5
1121
1178
  sample = missing.first(sample_size).map(&:gem_name)
@@ -1328,8 +1385,9 @@ module Rigor
1328
1385
  end
1329
1386
 
1330
1387
  def collect_plugin_diagnostics(plugin, path, root, scope)
1331
- raw = plugin.diagnostics_for_file(path: path, scope: scope, root: root)
1332
- 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) }
1333
1391
  rescue StandardError => e
1334
1392
  [plugin_runtime_error_diagnostic(path, plugin, e)]
1335
1393
  end
@@ -1463,6 +1521,7 @@ module Rigor
1463
1521
  unless @project_discovered_method_visibilities.empty?
1464
1522
  scope = scope.with_discovered_method_visibilities(@project_discovered_method_visibilities)
1465
1523
  end
1524
+ scope = scope.with_discovered_methods(@project_discovered_methods) unless @project_discovered_methods.empty?
1466
1525
  scope
1467
1526
  end
1468
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
@@ -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
@@ -13,7 +13,7 @@ module Rigor
13
13
  # tooling (SKILLs, CI, editor integrations) while text is
14
14
  # for interactive inspection. Rows are printed in the order
15
15
  # the loader resolved them.
16
- class PluginsRenderer
16
+ class PluginsRenderer # rubocop:disable Metrics/ClassLength
17
17
  def initialize(rows:, configuration_path:)
18
18
  @rows = rows
19
19
  @configuration_path = configuration_path
@@ -42,8 +42,74 @@ module Rigor
42
42
  )
43
43
  end
44
44
 
45
+ # ADR-37 § "Machine-readable capability catalogue" — the focused
46
+ # per-plugin extension-protocol dump. Only loaded plugins appear
47
+ # (a plugin that failed to load contributes no capabilities), and
48
+ # each carries only the gate values an agent enumerates to learn
49
+ # what the plugin does: node-rule node types, dynamic-return
50
+ # receivers, type-specifier methods, and produced / consumed facts.
51
+ def capabilities_json
52
+ JSON.pretty_generate(
53
+ {
54
+ "configuration" => @configuration_path,
55
+ "capabilities" => loaded_rows.map { |row| capabilities_json_for(row) }
56
+ }
57
+ )
58
+ end
59
+
60
+ def capabilities_text
61
+ lines = ["Plugin capability catalogue (ADR-37 narrow extension protocols)", ""]
62
+ loaded = loaded_rows
63
+ if loaded.empty?
64
+ lines << " (no plugins loaded)"
65
+ else
66
+ loaded.each_with_index do |row, index|
67
+ lines.concat(capability_lines(row))
68
+ lines << "" unless index == loaded.size - 1
69
+ end
70
+ end
71
+ lines.join("\n")
72
+ end
73
+
45
74
  private
46
75
 
76
+ def loaded_rows
77
+ @rows.select { |r| r[:status] == :loaded }
78
+ end
79
+
80
+ def capability_lines(row)
81
+ lines = [" #{row[:id]} v#{row[:version]} (#{row[:gem]})"]
82
+ capability_surfaces(row).each { |surface| lines << " #{surface}" }
83
+ lines << " (no narrow extension protocols declared)" if lines.size == 1
84
+ lines
85
+ end
86
+
87
+ # The non-empty capability surfaces for a plugin, each as a
88
+ # `label: a, b, c` string. Data-driven so the catalogue stays a
89
+ # single source of truth shared between the text and JSON views.
90
+ def capability_surfaces(row)
91
+ [
92
+ ["node_rule", row[:node_rule_types]],
93
+ ["dynamic_return receivers", row[:dynamic_return_receivers]],
94
+ ["type_specifier methods", row[:type_specifier_methods]],
95
+ ["produces", row[:produces]],
96
+ ["consumes", row[:consumes]]
97
+ ].filter_map { |label, values| "#{label}: #{values.join(', ')}" if values.any? }
98
+ end
99
+
100
+ def capabilities_json_for(row)
101
+ {
102
+ "id" => row[:id],
103
+ "gem" => row[:gem],
104
+ "version" => row[:version],
105
+ "node_rule_types" => row[:node_rule_types],
106
+ "dynamic_return_receivers" => row[:dynamic_return_receivers],
107
+ "type_specifier_methods" => row[:type_specifier_methods],
108
+ "produces" => row[:produces],
109
+ "consumes" => row[:consumes]
110
+ }
111
+ end
112
+
47
113
  def header
48
114
  loaded = @rows.count { |r| r[:status] == :loaded }
49
115
  errored = @rows.count { |r| r[:status] == :load_error }
@@ -99,6 +165,22 @@ module Rigor
99
165
  lines << " owns_receivers: #{row[:owns_receivers].join(', ')}" if row[:owns_receivers].any?
100
166
  lines << " produces: #{row[:produces].join(', ')}" if row[:produces].any?
101
167
  lines << " consumes: #{row[:consumes].join(', ')}" if row[:consumes].any?
168
+ lines.concat(narrow_protocol_lines(row))
169
+ lines
170
+ end
171
+
172
+ # ADR-37 narrow extension protocols (node_rule / dynamic_return /
173
+ # type_specifier). Surfaced in the full report alongside the
174
+ # declarative surfaces; `--capabilities` is the focused view.
175
+ def narrow_protocol_lines(row)
176
+ lines = []
177
+ lines << " node_rule: #{row[:node_rule_types].join(', ')}" if row[:node_rule_types].any?
178
+ if row[:dynamic_return_receivers].any?
179
+ lines << " dynamic_return receivers: #{row[:dynamic_return_receivers].join(', ')}"
180
+ end
181
+ if row[:type_specifier_methods].any?
182
+ lines << " type_specifier methods: #{row[:type_specifier_methods].join(', ')}"
183
+ end
102
184
  lines
103
185
  end
104
186
 
@@ -157,6 +239,9 @@ module Rigor
157
239
  "hkt_definitions" => row[:hkt_definitions],
158
240
  "protocol_contracts" => row[:protocol_contracts],
159
241
  "source_rbs_synthesizer" => row[:source_rbs_synthesizer],
242
+ "node_rule_types" => row[:node_rule_types],
243
+ "dynamic_return_receivers" => row[:dynamic_return_receivers],
244
+ "type_specifier_methods" => row[:type_specifier_methods],
160
245
  "load_error" => row[:load_error]
161
246
  }
162
247
  end