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
data/sig/rigor.rbs CHANGED
@@ -53,6 +53,11 @@ module Rigor
53
53
  def error?: () -> bool
54
54
  def to_h: () -> Hash[String, untyped]
55
55
  def to_s: () -> String
56
+
57
+ # Singleton factories plugins build diagnostics through (the
58
+ # `node` / `location` are Prism values, received as `untyped`).
59
+ def self.from_node: (untyped node, path: String, message: String, ?severity: Symbol, ?rule: String?, ?source_family: String, ?receiver_type: untyped, ?method_name: untyped, ?project_definition_site: untyped) -> Rigor::Analysis::Diagnostic
60
+ def self.from_location: (untyped location, path: String, message: String, ?severity: Symbol, ?rule: String?, ?source_family: String, ?receiver_type: untyped, ?method_name: untyped, ?project_definition_site: untyped) -> Rigor::Analysis::Diagnostic
56
61
  end
57
62
 
58
63
  class Result
@@ -90,20 +90,24 @@ Do NOT use this skill for:
90
90
  A Rigor plugin is a Ruby class that subclasses `Rigor::Plugin::Base`,
91
91
  declares a `manifest(id:, version:, …)`, and calls
92
92
  `Rigor::Plugin.register(self)` at load time. When `.rigor.yml` lists
93
- the plugin under `plugins:`, Rigor `require`s it and, for every
94
- analysed file, calls the plugin's `#diagnostics_for_file(path:,
95
- scope:, root:)` handing it the file's Prism AST (`root`) and a
96
- `scope` it can query for inferred types. The plugin walks the AST and
97
- returns an array of `Rigor::Analysis::Diagnostic`. Optionally it also
98
- implements `#flow_contribution_for(call_node:, scope:)` to *supply* a
99
- return type for call sites the core analyzer types as `Dynamic`.
93
+ the plugin under `plugins:`, Rigor `require`s it and runs its
94
+ **node rules**: the plugin declares `node_rule(Prism::CallNode) { |node,
95
+ scope, path, _fc, context| }`, and the engine which owns the single
96
+ AST walk per file hands every matching node to the block along with a
97
+ `scope` it can query for inferred types. The block returns an array of
98
+ `Rigor::Analysis::Diagnostic` (built via the `diagnostic` helper).
99
+ Optionally the plugin also declares `dynamic_return(receivers:)` /
100
+ `type_specifier(methods:)` to *supply* a return type or narrowing facts
101
+ for call sites the core analyzer types as `Dynamic`. (The older
102
+ `#diagnostics_for_file` / `#flow_contribution_for` fat hooks remain as
103
+ deprecated escape valves — see Phase 2.)
100
104
 
101
105
  ## Phase outline
102
106
 
103
107
  | Phase | What | Reference |
104
108
  | --- | --- | --- |
105
109
  | 1 | Package and scaffold — gem vs project-private layout, gemspec / Gemfile, the plugin class skeleton, `.rigor.yml` activation. | [`references/01-plan-and-scaffold.md`](references/01-plan-and-scaffold.md) |
106
- | 2 | The walker — `diagnostics_for_file`, building `Diagnostic`s, querying `scope.type_of`, optional `flow_contribution_for`, RBS for the DSL. | [`references/02-walker-and-types.md`](references/02-walker-and-types.md) |
110
+ | 2 | Node rules — `node_rule` (engine-owned walk), building `Diagnostic`s via `Base#diagnostic`, querying `scope.type_of`, calling the target library directly instead of reimplementing it (ADR-39: `Plugin::Inflector`, `Base.suggest`), optional `dynamic_return` / `type_specifier`, RBS for the DSL. | [`references/02-walker-and-types.md`](references/02-walker-and-types.md) |
107
111
  | 3 | Test and ship — fixture-based tests (RSpec / Minitest, no rigor internals), version pinning, README, publish or keep private. | [`references/03-test-and-ship.md`](references/03-test-and-ship.md) |
108
112
 
109
113
  ## Reading order — modules
@@ -111,5 +115,5 @@ return type for call sites the core analyzer types as `Dynamic`.
111
115
  | Module | Read | Covers |
112
116
  | --- | --- | --- |
113
117
  | 1 | [`references/01-plan-and-scaffold.md`](references/01-plan-and-scaffold.md) | **Phase 1.** The gem vs project-private packaging split, directory trees for both, gemspec template, project-private path-gem / `RUBYLIB` activation, the `Rigor::Plugin::Base` skeleton, `.rigor.yml` `plugins:` wiring. |
114
- | 2 | [`references/02-walker-and-types.md`](references/02-walker-and-types.md) | **Phase 2.** The `diagnostics_for_file` AST walk over Prism nodes, the `Diagnostic` constructor shape, asking the analyzer for inferred types via `scope.type_of`, the optional `flow_contribution_for` return-type hook, and shipping `sig/*.rbs` so the DSL's types are visible. |
118
+ | 2 | [`references/02-walker-and-types.md`](references/02-walker-and-types.md) | **Phase 2.** The `node_rule` engine-owned AST walk over Prism nodes, the `Base#diagnostic` helper, asking the analyzer for inferred types via `scope.type_of`, two-pass / lexical context (`node_file_context` / `NodeContext`), the optional `dynamic_return` / `type_specifier` return-type hooks (and the deprecated `flow_contribution_for` escape valve), calling the target library's pure methods directly rather than reimplementing them (ADR-39: `Plugin::Inflector` over the real `ActiveSupport::Inflector`; `Base.suggest` for did-you-mean), and shipping `sig/*.rbs` so the DSL's types are visible. |
115
119
  | 3 | [`references/03-test-and-ship.md`](references/03-test-and-ship.md) | **Phase 3.** Testing a plugin from outside the monorepo — fixture projects driven through `rigor check --format json`, plus pure unit tests of dispatch tables — with RSpec or Minitest. Version pinning against the pre-1.0 contract. README. Publishing to RubyGems or keeping the plugin private. |
@@ -184,10 +184,11 @@ module Rigor
184
184
  @module_name = config.fetch("module_name", "Default")
185
185
  end
186
186
 
187
- # Called per analysed file. `root` is the file's Prism AST,
188
- # `scope` answers inferred-type queries. Return an Array of
189
- # Rigor::Analysis::Diagnostic. See 02-walker-and-types.md.
190
- def diagnostics_for_file(path:, scope:, root:)
187
+ # The engine owns the AST walk and hands every matching node to
188
+ # this rule. `scope` answers inferred-type queries; return an
189
+ # Array of Rigor::Analysis::Diagnostic (built via `diagnostic`).
190
+ # See 02-walker-and-types.md.
191
+ node_rule Prism::CallNode do |node, scope, path, _file_context, _context|
191
192
  []
192
193
  end
193
194
  end
@@ -227,7 +228,7 @@ inert.
227
228
 
228
229
  ## Output of this module
229
230
 
230
- A scaffolded plugin that loads (even if `diagnostics_for_file` still
231
+ A scaffolded plugin that loads (even if its `node_rule` still
231
232
  returns `[]`) and is activated in `.rigor.yml`. Proceed to Phase 2
232
233
  ([`02-walker-and-types.md`](02-walker-and-types.md)) to make it
233
234
  actually analyse.
@@ -1,57 +1,94 @@
1
- # 02 — The walker and types
1
+ # 02 — Node rules and types
2
2
 
3
- Covers **Phase 2** — making `diagnostics_for_file` analyse the AST,
4
- emit diagnostics, and (optionally) contribute return types.
3
+ Covers **Phase 2** — recognising the DSL's call shapes, emitting
4
+ diagnostics, and (optionally) contributing return types. The engine
5
+ owns the AST walk; you write the per-node check.
5
6
 
6
- ## `diagnostics_for_file` — the core hook
7
+ ## `node_rule` — the core hook (ADR-37)
8
+
9
+ A diagnostic-emitting plugin declares a **node rule**. The engine walks
10
+ each analysed file's AST **once** and hands every node of the declared
11
+ type to your block — so you write the check and never the traversal:
7
12
 
8
13
  ```ruby
9
- def diagnostics_for_file(path:, scope:, root:)
10
- # root — Prism::Node, the parsed file (a ProgramNode).
11
- # scope — answers inferred-type queries: scope.type_of(node).
12
- # path — the file path, for Diagnostic#path.
13
- # return Array<Rigor::Analysis::Diagnostic>
14
+ class MyPlugin < Rigor::Plugin::Base
15
+ manifest(id: "my-plugin", version: "0.1.0")
16
+
17
+ node_rule Prism::CallNode do |node, scope, path, file_context, context|
18
+ # node — the Prism::CallNode the engine matched.
19
+ # scope — answers inferred-type queries: scope.type_of(node).
20
+ # path — the file path, for the diagnostic location.
21
+ # file_context — the value node_file_context built (see below); nil if none.
22
+ # context — Rigor::Plugin::NodeContext: lexical ancestors.
23
+ # → return Array<Rigor::Analysis::Diagnostic> ([] to fire nothing)
24
+ end
14
25
  end
15
26
  ```
16
27
 
17
- Walk `root` with a Prism visitor or a recursive descent, recognise
18
- the DSL's call shapes, and collect diagnostics. Keep the walk in a
19
- separate `Analyzer` class once it grows past a few methods — pass it
20
- `path` and let it return the diagnostic array.
28
+ `node_rule(node_type)` matches by `node.is_a?(node_type)`, so declare
29
+ the concrete class you care about (`Prism::CallNode` for almost every
30
+ DSL plugin) or `Prism::Node` to see everything. The block runs through
31
+ `instance_exec`, so `config`, `services`, `services.fact_store`, and the
32
+ `diagnostic` helper (below) are all in scope. Declare multiple rules
33
+ (they run in declaration order); a plugin that declares none pays zero
34
+ cost. Keep the matching logic in a separate `Analyzer` class once it
35
+ grows — have it take the call node and return location-free `Violation`
36
+ rows the rule positions with `diagnostic` (the bundled plugins follow
37
+ this `Analyzer.violations_for` split, which also makes the logic
38
+ unit-testable without running the whole engine).
39
+
40
+ > The legacy `diagnostics_for_file(path:, scope:, root:)` hook — where
41
+ > you hand-rolled a `def walk` / `compact_child_nodes.each` recursion
42
+ > over `root` yourself — is the **deprecated whole-file escape valve**.
43
+ > Use it only for a genuinely file-scoped result (a single load-error
44
+ > row, or a check that needs the whole parsed file at once). New
45
+ > node-scoped checks use `node_rule`.
21
46
 
22
47
  ### Recognising call sites
23
48
 
24
- Most DSL plugins key off `Prism::CallNode`:
25
-
26
- ```ruby
27
- def each_call(node, &block)
28
- block.call(node) if node.is_a?(Prism::CallNode)
29
- node&.compact_child_nodes&.each { |child| each_call(child, &block) }
30
- end
31
- ```
32
-
33
- Then match on `node.name` (the method name, a Symbol),
34
- `node.receiver`, and `node.arguments&.arguments`.
49
+ Inside the rule, match on `node.name` (the method name, a Symbol),
50
+ `node.receiver` (nil for an implicit-self call), and
51
+ `node.arguments&.arguments`. For literal `:sym` / `"str"` arguments
52
+ reach for `Rigor::Source::Literals` (`symbol_or_string(node)` /
53
+ `symbol_arguments(call)` / `symbol_arg(call, i)`) rather than
54
+ re-deriving the `unescaped.to_sym` shape.
55
+
56
+ ### Two-pass and lexical context
57
+
58
+ - **Same-file collect-then-validate** (gather declared names, then
59
+ validate references): declare `node_file_context { |root, scope| … }`.
60
+ It runs once per file before the walk and its return value is threaded
61
+ to every rule as `file_context`. (A *cross-file* collect belongs in
62
+ `#prepare` + `services.fact_store` instead — see
63
+ [`01-plan-and-scaffold.md`](01-plan-and-scaffold.md).)
64
+ - **Where the node sits**: `context` (a `Rigor::Plugin::NodeContext`)
65
+ carries the lexical ancestor chain — `context.enclosing_def`,
66
+ `context.enclosing_module`, `context.enclosing_block(:describe)`, and
67
+ the raw `context.ancestors`. Read it when the check depends on the
68
+ enclosing class / method / block DSL (the enclosing controller a
69
+ `before_action` sits in, the `describe <Model>` a matcher is under).
35
70
 
36
71
  ## Building a `Diagnostic`
37
72
 
38
- Every diagnostic the plugin emits is a `Rigor::Analysis::Diagnostic`.
39
- A small constructor helper keeps the call sites clean:
73
+ Use the `Base#diagnostic` helper it internalises the load-bearing
74
+ 1-based `line` / `start_column + 1` convention so you never unpack
75
+ `node.location` by hand:
40
76
 
41
77
  ```ruby
42
- def diagnostic(path, node, severity:, rule:, message:)
43
- loc = node.location
44
- Rigor::Analysis::Diagnostic.new(
45
- path: path,
46
- line: loc.start_line,
47
- column: loc.start_column + 1, # 1-based column
48
- message: message,
49
- severity: severity, # :error | :warning | :info
50
- rule: rule # short kebab-case id
51
- )
78
+ node_rule Prism::CallNode do |node, scope, path, _fc, _ctx|
79
+ next [] unless offending?(node)
80
+
81
+ [diagnostic(node, path: path, message: "...", severity: :error, rule: "my-rule")]
52
82
  end
53
83
  ```
54
84
 
85
+ Pass `location:` (a Prism location) to point at a sub-location rather
86
+ than the whole node — typically `location: node.message_loc`, so a
87
+ method-name diagnostic points at the name, not the receiver-spanning
88
+ whole call. Do **not** set `source_family` — the runner stamps
89
+ `plugin.<manifest.id>` on every returned diagnostic, so anything you set
90
+ is overwritten.
91
+
55
92
  `rule` is a short identifier (`dimension-mismatch`,
56
93
  `unknown-state`). Rigor namespaces it under your plugin —
57
94
  diagnostics surface as `plugin.<manifest.id>.<rule>`, and that
@@ -87,46 +124,80 @@ Match with `case`/`when` on the carrier class. Treat any carrier you
87
124
  do not recognise as "decline to act" — never crash on an unexpected
88
125
  type.
89
126
 
90
- ## Optional contribute a return type with `flow_contribution_for`
127
+ ## Use the target library, don't reimplement it (ADR-39)
128
+
129
+ A plugin may **call the pure methods of the library it targets
130
+ directly** instead of reimplementing them — the Ruby analogue of a
131
+ PHPStan extension calling into the real framework. Reimplementing a
132
+ library's rules (an inflector, a status-code table, …) risks diverging
133
+ from the real behaviour, and a wrong *derived* fact (a wrong class /
134
+ helper / column name) is a false positive on working code. So declare
135
+ the target gem as your plugin's dependency and call its safe methods.
91
136
 
92
- > **Critical this hook does NOT make a method "defined", so it does
137
+ The bundled `Rigor::Plugin::Inflector` is the worked example it calls
138
+ the real `ActiveSupport::Inflector` rather than a hand-rolled
139
+ approximation. If your plugin needs inflection, use it:
140
+
141
+ ```ruby
142
+ Rigor::Plugin::Inflector.classify("users") # => "User"
143
+ Rigor::Plugin::Inflector.tableize("BlogPost") # => "blog_posts"
144
+ ```
145
+
146
+ For your own target library, follow the same harness: a **fixed
147
+ allow-list** of pure methods, inputs derived from source, and **decline
148
+ (return nil / emit nothing) when the library is unavailable — never
149
+ approximate**. How the call is isolated from Rigor (in-process, a forked
150
+ worker, or a `Ruby::Box`) is a configurable strategy the user picks
151
+ (`plugins_isolation:`); you just call the method.
152
+
153
+ For the common "did you mean …?" suggestion, use the shared helper
154
+ rather than hand-rolling Levenshtein:
155
+
156
+ ```ruby
157
+ Rigor::Plugin::Base.suggest(typo, known_names) # nearest match, or nil
158
+ ```
159
+
160
+ ## Optional — contribute a return type with `dynamic_return` / `type_specifier`
161
+
162
+ > **Critical — these hooks do NOT make a method "defined", so they do
93
163
  > NOT suppress `call.undefined-method`.** Method *existence* and call
94
- > *type* are two independent checks. `flow_contribution_for` sharpens
164
+ > *type* are two independent checks. A return-type contribution sharpens
95
165
  > the type of a call the analyzer has **already resolved to a real
96
166
  > method** (turning a `Dynamic` return into something precise). It is
97
167
  > never consulted for a receiver/method the analyzer cannot find — that
98
- > fires `call.undefined-method` first, and a flow contribution does
99
- > nothing to silence it. **If your goal is to kill a
100
- > `call.undefined-method` cluster on a DSL-generated method (the common
101
- > reason `rigor-project-init` hands off to this skill), the fix is to
102
- > make the method *exist* in Rigor's view — ship RBS declaring it (see
103
- > "Shipping RBS for the DSL" below), not a flow contribution.** Reach
104
- > for `flow_contribution_for` only when the call already resolves and
105
- > you want a *better return type*.
168
+ > fires `call.undefined-method` first, and a contribution does nothing
169
+ > to silence it. **If your goal is to kill a `call.undefined-method`
170
+ > cluster on a DSL-generated method (the common reason
171
+ > `rigor-project-init` hands off to this skill), the fix is to make the
172
+ > method *exist* in Rigor's view — ship RBS declaring it (see "Shipping
173
+ > RBS for the DSL" below), not a return-type contribution.** Reach for
174
+ > these only when the call already resolves and you want a *better
175
+ > return type*.
106
176
 
107
177
  A plugin can do more than emit diagnostics: it can *supply* the
108
- inferred return type for a call site the core analyzer would
109
- otherwise type as `Dynamic`. Implement `flow_contribution_for`:
178
+ inferred return type (or narrowing facts) for a call site the core
179
+ analyzer would otherwise type as `Dynamic`. ADR-37 gives two narrow,
180
+ declaratively-gated DSLs — prefer them; the engine indexes plugins by
181
+ their gate so it only calls the block for matching calls:
110
182
 
111
183
  ```ruby
112
- def flow_contribution_for(call_node:, scope:)
113
- return nil unless call_node.is_a?(Prism::CallNode)
114
- # ... decide the call site's real return type ...
115
- return nil if undecidable # nil = "I have nothing to add"
116
-
117
- Rigor::FlowContribution.new(
118
- return_type: a_rigor_type,
119
- provenance: Rigor::FlowContribution::Provenance.new(
120
- source_family: "plugin.#{manifest.id}",
121
- plugin_id: manifest.id,
122
- node: call_node,
123
- descriptor: nil
124
- )
125
- )
184
+ # Per-call-site RETURN TYPE, gated on the receiver's class.
185
+ # The block fires only when the receiver type's class is (or inherits
186
+ # from) a declared `receivers:` entry. Return a Rigor::Type or nil.
187
+ dynamic_return receivers: ["Money"] do |call_node, scope|
188
+ next nil unless call_node.name == :+
189
+ Rigor::Type::Combinator.nominal_of("Money")
190
+ end
191
+
192
+ # Post-return NARROWING FACTS, gated on the call's method name.
193
+ # Return an Array of facts (or nil). Used for assertion / predicate
194
+ # narrowing (`assert_kind_of(Foo, x)` ⇒ x is Foo afterwards).
195
+ type_specifier methods: [:assert_kind_of] do |call_node, scope|
196
+ # ... build and return the post-return facts ...
126
197
  end
127
198
  ```
128
199
 
129
- Build the `return_type` with `Rigor::Type::Combinator`:
200
+ Build return types with `Rigor::Type::Combinator`:
130
201
 
131
202
  ```ruby
132
203
  Rigor::Type::Combinator.nominal_of("Money") # a class type
@@ -134,14 +205,27 @@ Rigor::Type::Combinator.constant_of(true) # a literal
134
205
  Rigor::Type::Combinator.union(a, b) # a union
135
206
  ```
136
207
 
137
- Returning `nil` is always safe — it means "no contribution", and the
138
- core analyzer keeps its own answer. Contribute a type only when the
208
+ Returning `nil` (or `[]`) is always safe — it means "no contribution",
209
+ and the core analyzer keeps its own answer. Contribute only when the
139
210
  plugin is confident; a wrong contribution propagates downstream.
140
211
 
141
- This hook is the most contract-sensitive surface — it is the part
142
- most likely to shift before v0.2.0. Implement it only if the plugin
143
- genuinely needs to sharpen call-site types; a diagnostics-only
144
- plugin can skip it entirely.
212
+ `receivers:` / `methods:` are the greppable gates the
213
+ `rigor plugins --capabilities` catalogue enumerates run it to see
214
+ exactly what each loaded plugin contributes.
215
+
216
+ > **The deprecated escape valve.** The original fat hook,
217
+ > `flow_contribution_for(call_node:, scope:)`, returns a single
218
+ > `Rigor::FlowContribution` and is still consulted alongside the narrow
219
+ > DSLs. It is retained only for the two shapes the narrow DSLs do not
220
+ > express: a **method-gated return type** (an RSpec `let(:x) { … }`
221
+ > binding, a Sorbet `sig`-driven return — keyed on the method, not a
222
+ > fixed receiver class) and a **dynamic per-project receiver set**
223
+ > (ActiveStorage's `Attached::One` on discovered model classes). If your
224
+ > plugin needs one of those, use `flow_contribution_for`; otherwise
225
+ > prefer `dynamic_return` / `type_specifier`. These return-type surfaces
226
+ > are the most contract-sensitive part of the API — implement one only
227
+ > if the plugin genuinely needs to sharpen call-site types; a
228
+ > diagnostics-only plugin skips them entirely.
145
229
 
146
230
  ## Shipping RBS for the DSL — the way to suppress `call.undefined-method`
147
231
 
@@ -151,7 +235,7 @@ If the DSL introduces methods or classes that Rigor cannot see (a
151
235
  Rigor RBS declaring them so *core* inference — not just your plugin —
152
236
  treats them as **defined**. This is what removes the
153
237
  `call.undefined-method` diagnostics on those methods; nothing else
154
- (not `diagnostics_for_file`, not `flow_contribution_for`) makes a
238
+ (not a `node_rule`, not `dynamic_return` / `type_specifier`) makes a
155
239
  method exist in Rigor's view.
156
240
 
157
241
  Two ways to wire the RBS, depending on how the plugin is packaged:
@@ -197,8 +281,8 @@ They compose — many plugins ship both.
197
281
 
198
282
  ## Output of this module
199
283
 
200
- A plugin whose `diagnostics_for_file` recognises the DSL and emits
201
- diagnostics with correct severities and rule ids — optionally a
202
- `flow_contribution_for` and a `sig/` bundle. Verify by eye with
203
- `rigor check`; lock it down with tests in Phase 3
284
+ A plugin whose `node_rule`(s) recognise the DSL and emit diagnostics
285
+ with correct severities and rule ids — optionally a `dynamic_return` /
286
+ `type_specifier` and a `sig/` bundle. Verify by eye with `rigor check`;
287
+ lock it down with tests in Phase 3
204
288
  ([`03-test-and-ship.md`](03-test-and-ship.md)).
@@ -114,9 +114,9 @@ SKILL.md). Concretely:
114
114
  - Your plugin's own version is normal semver, independent of
115
115
  `rigortype`'s.
116
116
  - When you bump the `rigortype` pin to a new minor, **re-run the
117
- full test suite** — the walker hook signature or the `Diagnostic` /
118
- type-carrier shapes may have changed. The fixture CLI tests are
119
- what catch a contract drift.
117
+ full test suite** — the `node_rule` / contribution hook signatures
118
+ or the `Diagnostic` / type-carrier shapes may have changed. The
119
+ fixture CLI tests are what catch a contract drift.
120
120
  - State the supported `rigortype` range in the README so users do
121
121
  not pair the plugin with an incompatible Rigor.
122
122
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rigortype
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.15
4
+ version: 0.1.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rigor contributors
@@ -69,6 +69,46 @@ dependencies:
69
69
  - - "<"
70
70
  - !ruby/object:Gem::Version
71
71
  version: '5.0'
72
+ - !ruby/object:Gem::Dependency
73
+ name: activesupport
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '7.0'
79
+ - - "<"
80
+ - !ruby/object:Gem::Version
81
+ version: '9.0'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '7.0'
89
+ - - "<"
90
+ - !ruby/object:Gem::Version
91
+ version: '9.0'
92
+ - !ruby/object:Gem::Dependency
93
+ name: rack
94
+ requirement: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '2.0'
99
+ - - "<"
100
+ - !ruby/object:Gem::Version
101
+ version: '4.0'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '2.0'
109
+ - - "<"
110
+ - !ruby/object:Gem::Version
111
+ version: '4.0'
72
112
  - !ruby/object:Gem::Dependency
73
113
  name: parallel_tests
74
114
  requirement: !ruby/object:Gem::Requirement
@@ -326,6 +366,7 @@ files:
326
366
  - lib/rigor/flow_contribution/merger.rb
327
367
  - lib/rigor/inference/acceptance.rb
328
368
  - lib/rigor/inference/block_parameter_binder.rb
369
+ - lib/rigor/inference/budget_trace.rb
329
370
  - lib/rigor/inference/builtins/array_catalog.rb
330
371
  - lib/rigor/inference/builtins/comparable_catalog.rb
331
372
  - lib/rigor/inference/builtins/complex_catalog.rb
@@ -411,18 +452,24 @@ files:
411
452
  - lib/rigor/mcp/server.rb
412
453
  - lib/rigor/plugin.rb
413
454
  - lib/rigor/plugin/access_denied_error.rb
455
+ - lib/rigor/plugin/additional_initializer.rb
414
456
  - lib/rigor/plugin/base.rb
415
457
  - lib/rigor/plugin/blueprint.rb
458
+ - lib/rigor/plugin/box.rb
416
459
  - lib/rigor/plugin/fact_store.rb
460
+ - lib/rigor/plugin/inflector.rb
417
461
  - lib/rigor/plugin/io_boundary.rb
462
+ - lib/rigor/plugin/isolation.rb
418
463
  - lib/rigor/plugin/load_error.rb
419
464
  - lib/rigor/plugin/loader.rb
420
465
  - lib/rigor/plugin/macro.rb
421
466
  - lib/rigor/plugin/macro/block_as_method.rb
422
467
  - lib/rigor/plugin/macro/external_file.rb
423
468
  - lib/rigor/plugin/macro/heredoc_template.rb
469
+ - lib/rigor/plugin/macro/nested_class_template.rb
424
470
  - lib/rigor/plugin/macro/trait_registry.rb
425
471
  - lib/rigor/plugin/manifest.rb
472
+ - lib/rigor/plugin/node_context.rb
426
473
  - lib/rigor/plugin/protocol_contract.rb
427
474
  - lib/rigor/plugin/registry.rb
428
475
  - lib/rigor/plugin/services.rb
@@ -447,6 +494,7 @@ files:
447
494
  - lib/rigor/sig_gen/write_result.rb
448
495
  - lib/rigor/sig_gen/writer.rb
449
496
  - lib/rigor/source.rb
497
+ - lib/rigor/source/literals.rb
450
498
  - lib/rigor/source/node_locator.rb
451
499
  - lib/rigor/source/node_walker.rb
452
500
  - lib/rigor/testing.rb
@@ -506,7 +554,6 @@ files:
506
554
  - plugins/rigor-activerecord/lib/rigor-activerecord.rb
507
555
  - plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb
508
556
  - plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb
509
- - plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb
510
557
  - plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb
511
558
  - plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb
512
559
  - plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb
@@ -546,6 +593,8 @@ files:
546
593
  - plugins/rigor-hanami/lib/rigor/plugin/hanami.rb
547
594
  - plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb
548
595
  - plugins/rigor-hanami/sig/hanami_action.rbs
596
+ - plugins/rigor-mangrove/lib/rigor-mangrove.rb
597
+ - plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb
549
598
  - plugins/rigor-minitest/lib/rigor-minitest.rb
550
599
  - plugins/rigor-minitest/lib/rigor/plugin/minitest.rb
551
600
  - plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb
@@ -680,6 +729,7 @@ require_paths:
680
729
  - plugins/rigor-factorybot/lib
681
730
  - plugins/rigor-graphql/lib
682
731
  - plugins/rigor-hanami/lib
732
+ - plugins/rigor-mangrove/lib
683
733
  - plugins/rigor-minitest/lib
684
734
  - plugins/rigor-pundit/lib
685
735
  - plugins/rigor-rails/lib
@@ -1,114 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Rigor
4
- module Plugin
5
- class Activerecord < Rigor::Plugin::Base
6
- # Tiny inflection helper for the common `ClassName → snake_case_plural`
7
- # mapping Rails uses to derive table names. Deliberately
8
- # narrow — handles the regular cases (`User → users`,
9
- # `BlogPost → blog_posts`, `Category → categories`,
10
- # `Bus → buses`, `Wolf → wolves`). Irregular plurals
11
- # (`Person → people`, `Mouse → mice`, `Datum → data`) are
12
- # NOT handled; the user is expected to declare
13
- # `self.table_name = "people"` for those.
14
- #
15
- # Avoids an `activesupport` runtime dependency. Rails apps
16
- # that need richer inflection should set explicit table
17
- # names on the affected models.
18
- module Inflector
19
- IRREGULAR_PLURALS = {
20
- # Common ones we still want to handle without bringing
21
- # in a full inflection table. Users get the configured
22
- # explicit table_name route for anything else.
23
- "person" => "people",
24
- "child" => "children",
25
- "datum" => "data"
26
- }.freeze
27
-
28
- module_function
29
-
30
- # `BlogPost` → `blog_posts`. `User::Profile` → `user_profiles`
31
- # (Rails-style namespacing flattens with underscore).
32
- def tableize(class_name)
33
- underscore = underscore(class_name.to_s.gsub("::", "/"))
34
- # `user/profiles` → `user_profiles`
35
- underscore = underscore.tr("/", "_")
36
- pluralize(underscore)
37
- end
38
-
39
- # `BlogPost` → `blog_post`. Standard Rails-style underscore.
40
- def underscore(camel_case_word)
41
- word = camel_case_word.to_s.dup
42
- word.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
43
- word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
44
- word.tr!("-", "_")
45
- word.downcase!
46
- word
47
- end
48
-
49
- # `users` → `User`, `blog_posts` → `BlogPost`.
50
- # Used by the association detector to map an
51
- # association NAME (`has_many :posts` → `Post`) to the
52
- # target class without requiring an explicit
53
- # `class_name:` option. Singularises the word first,
54
- # then camel-cases. Recognises the same irregular
55
- # plurals as {.pluralize}.
56
- def classify(word)
57
- camelize(singularize(word.to_s))
58
- end
59
-
60
- # `posts` → `post`, `categories` → `category`,
61
- # `wolves` → `wolf`, `buses` → `bus`. The inverse of
62
- # {.pluralize} for the regular cases this module
63
- # recognises. Irregular forms (`people` → `person`)
64
- # round-trip via {IRREGULAR_PLURALS}.
65
- def singularize(word)
66
- IRREGULAR_PLURALS.each { |singular, plural| return singular if word == plural }
67
-
68
- case word
69
- when /(.*[bcdfghjklmnpqrstvwxz])ies\z/
70
- "#{Regexp.last_match(1)}y"
71
- when /(.*[sxz]|.*[cs]h)es\z/
72
- Regexp.last_match(0)[0..-3]
73
- when /(.*)ves\z/
74
- "#{Regexp.last_match(1)}f"
75
- when /(.+)s\z/
76
- Regexp.last_match(1)
77
- else
78
- word
79
- end
80
- end
81
-
82
- # `blog_post` → `BlogPost`. Camelizes around `_` and
83
- # `/` separators; the latter promotes namespace boundaries
84
- # to `::` (Rails-style).
85
- def camelize(snake)
86
- snake.to_s.split("/").map do |segment|
87
- segment.split("_").map { |part| part.empty? ? part : part[0].upcase + part[1..] }.join
88
- end.join("::")
89
- end
90
-
91
- # `user` → `users`, `category` → `categories`,
92
- # `bus` → `buses`, `wolf` → `wolves`. Falls back to a
93
- # plain `+ "s"` for unrecognised endings.
94
- def pluralize(word)
95
- return IRREGULAR_PLURALS[word] if IRREGULAR_PLURALS.key?(word)
96
-
97
- case word
98
- when /(.*[bcdfghjklmnpqrstvwxz])y\z/
99
- # `category` → `categories`, `cherry` → `cherries`
100
- "#{Regexp.last_match(1)}ies"
101
- when /(.*[sxz]|.*[cs]h)\z/
102
- # `bus` → `buses`, `box` → `boxes`, `dish` → `dishes`
103
- "#{Regexp.last_match(0)}es"
104
- when /(.*)fe?\z/
105
- # `wolf` → `wolves`, `knife` → `knives`
106
- "#{Regexp.last_match(1)}ves"
107
- else
108
- "#{word}s"
109
- end
110
- end
111
- end
112
- end
113
- end
114
- end