rigortype 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/baseline_command.rb +4 -3
  7. data/lib/rigor/cli/plugins_command.rb +308 -0
  8. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  9. data/lib/rigor/cli.rb +44 -3
  10. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  11. data/lib/rigor/inference/expression_typer.rb +69 -30
  12. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  13. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  14. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  15. data/lib/rigor/inference/mutation_widening.rb +285 -0
  16. data/lib/rigor/inference/narrowing.rb +72 -4
  17. data/lib/rigor/inference/scope_indexer.rb +409 -12
  18. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  19. data/lib/rigor/scope.rb +181 -4
  20. data/lib/rigor/version.rb +1 -1
  21. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  22. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  23. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  24. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  25. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  26. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
  27. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
  28. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
  29. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
  30. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
  32. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
  33. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
  34. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
  35. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  36. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  37. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  38. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  39. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  40. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  41. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
  42. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  43. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  44. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  45. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
  46. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
  47. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
  48. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  49. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  50. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  53. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  54. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  55. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
  56. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  57. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
  58. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  59. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  60. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  62. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  63. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  64. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  65. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  66. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  67. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  68. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  69. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  70. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  71. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  72. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  73. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  74. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  75. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  76. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  77. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  78. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  79. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  80. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  81. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  82. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  83. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  84. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  85. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  86. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  87. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  88. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  89. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  90. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  91. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  92. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  93. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
  94. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  96. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
  97. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  98. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
  99. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  100. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  101. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  102. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
  103. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
  104. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
  105. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  106. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  107. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  108. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  109. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  110. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  111. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  112. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  113. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  114. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  115. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  116. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  117. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  118. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  119. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  120. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  121. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  122. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  123. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  124. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  125. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  126. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  127. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  128. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  129. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  130. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  131. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  132. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  133. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  134. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  135. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  136. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  137. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  138. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  139. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  140. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  141. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  142. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  143. data/sig/rigor/scope.rbs +22 -0
  144. metadata +157 -1
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "rigor/plugin"
5
+
6
+ require_relative "hanami/action_checker"
7
+
8
+ module Rigor
9
+ module Plugin
10
+ # rigor-hanami — enforces the Hanami::Action protocol.
11
+ #
12
+ # Hanami 3.x actions inherit from `Hanami::Action` and
13
+ # define a single entry-point:
14
+ #
15
+ # def handle(request, response)
16
+ # response.status = 200
17
+ # response.body = "Hello"
18
+ # end
19
+ #
20
+ # The method is void — the response is mutated in-place;
21
+ # the return value is discarded by the framework.
22
+ #
23
+ # This plugin uses the **ADR-28 path-scoped
24
+ # method-protocol contract** to:
25
+ #
26
+ # 1. **Provide** `Hanami::Action::Request` and
27
+ # `Hanami::Action::Response` as the types of the
28
+ # first and second parameters of every `#handle`
29
+ # method defined inside `app/actions/**/*.rb`.
30
+ # The engine substitutes these types for the usual
31
+ # `Dynamic[Top]` fallback, so misuse of `request` or
32
+ # `response` inside an action body surfaces as a
33
+ # core diagnostic.
34
+ #
35
+ # 2. **Check** that every class under `app/actions/`
36
+ # defines `#handle`. Missing definitions emit
37
+ # `missing-handle-method`.
38
+ #
39
+ # Return-type conformance is NOT checked — Hanami
40
+ # actions are void by contract, and checking a void
41
+ # return would produce false positives on every `if`
42
+ # branch that doesn't end with an explicit `nil`.
43
+ #
44
+ # ## Configuration
45
+ #
46
+ # plugins:
47
+ # - gem: rigor-hanami
48
+ # config:
49
+ # action_path: "app/actions/**/*.rb" # default; optional
50
+ #
51
+ # ## Diagnostics
52
+ #
53
+ # | Event | Severity | Rule |
54
+ # | --- | --- | --- |
55
+ # | action class defines no `#handle` | `:error` | `missing-handle-method` |
56
+ # | `request` / `response` misuse in body | core engine diagnostic |
57
+ #
58
+ # A misused `request` or `response` (e.g.
59
+ # `request.no_such_method`) surfaces as a standard
60
+ # engine `call.undefined-method` diagnostic once the
61
+ # plugin provides the parameter types.
62
+ class Hanami < Rigor::Plugin::Base
63
+ manifest(
64
+ id: "hanami",
65
+ version: "0.1.0",
66
+ description: "Enforces the Hanami::Action protocol: #handle(request, response) → void.",
67
+ config_schema: {
68
+ "action_path" => :string
69
+ },
70
+ signature_paths: ["sig"],
71
+ protocol_contracts: [
72
+ Rigor::Plugin::ProtocolContract.new(
73
+ path_glob: "app/actions/**/*.rb",
74
+ method_name: :handle,
75
+ param_types: [
76
+ { index: 0, type_name: "Hanami::Action::Request" },
77
+ { index: 1, type_name: "Hanami::Action::Response" }
78
+ ]
79
+ # return_type_name: nil — void; skip return-type conformance check
80
+ )
81
+ ]
82
+ )
83
+
84
+ def init(_services)
85
+ override = config["action_path"]
86
+ @protocol_contracts =
87
+ if override.nil? || override.to_s.empty?
88
+ manifest.protocol_contracts
89
+ else
90
+ manifest.protocol_contracts.map { |c| c.with_path_glob(override.to_s) }
91
+ end
92
+ end
93
+
94
+ # ADR-28: override so the per-project `action_path` config
95
+ # reaches both the engine's parameter-provision tier and
96
+ # this plugin's check half.
97
+ def protocol_contracts
98
+ @protocol_contracts || manifest.protocol_contracts
99
+ end
100
+
101
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
102
+ contracts = protocol_contracts
103
+ return [] if contracts.empty?
104
+
105
+ ActionChecker.new(contracts: contracts).check(path: path, root: root)
106
+ end
107
+ end
108
+
109
+ Rigor::Plugin.register(Hanami)
110
+ end
111
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/hanami"
@@ -0,0 +1,78 @@
1
+ # Minimal Hanami::Action type stubs for rigor-hanami.
2
+ #
3
+ # Hanami::Action::Request and Hanami::Action::Response are
4
+ # defined as standalone types (without declaring Rack::Request /
5
+ # Rack::Response inheritance) so this file is self-contained
6
+ # regardless of whether a Rack RBS bundle is present in the
7
+ # analysed project.
8
+ #
9
+ # The methods listed here cover the surface documented at:
10
+ # https://guides.hanamirb.org/v3.0/actions/request-and-response/
11
+
12
+ module Hanami
13
+ module Action
14
+ # Represents the incoming HTTP request inside `#handle`.
15
+ class Request
16
+ def path_info: () -> String
17
+ def request_method: () -> String
18
+ def get?: () -> bool
19
+ def post?: () -> bool
20
+ def patch?: () -> bool
21
+ def put?: () -> bool
22
+ def delete?: () -> bool
23
+ def head?: () -> bool
24
+ def xhr?: () -> bool
25
+ def params: () -> Hanami::Action::Params
26
+ def session: () -> Hash[Symbol, untyped]
27
+ def flash: () -> Hash[Symbol, untyped]
28
+ def content_type: () -> String?
29
+ def env: () -> Hash[String, untyped]
30
+ def body: () -> String
31
+ def get_header: (String name) -> String?
32
+ def ip: () -> String?
33
+ def referer: () -> String?
34
+ def user_agent: () -> String?
35
+ def subdomain: (?Integer tld_length) -> String?
36
+ def subdomains: (?Integer tld_length) -> Array[String]
37
+ def cookies: () -> Hash[String, String]
38
+ def host: () -> String
39
+ def port: () -> Integer
40
+ def url: () -> String
41
+ def base_url: () -> String
42
+ def query_string: () -> String
43
+ def multipart?: () -> bool
44
+ def form_data?: () -> bool
45
+ end
46
+
47
+ # Represents the outgoing HTTP response inside `#handle`.
48
+ # The response is mutated in-place; the `#handle` return
49
+ # value is discarded by the framework.
50
+ class Response
51
+ def status: () -> Integer
52
+ def status=: (Integer | Symbol) -> void
53
+ def body: () -> Array[String]
54
+ def body=: (String | Array[String]) -> void
55
+ def headers: () -> Hash[String, String]
56
+ def format=: (Symbol | String) -> void
57
+ def redirect_to: (String url, ?Integer status) -> void
58
+ def write: (String str) -> void
59
+ def set_cookie: (String name, ?(String | Hash[Symbol, untyped]) value) -> void
60
+ def delete_cookie: (String name, ?Hash[Symbol, untyped] options) -> void
61
+ def content_type: () -> String
62
+ def content_type=: (String) -> void
63
+ end
64
+
65
+ # Typed parameter container. Values are coerced by the
66
+ # action's `params` block schema (if declared); otherwise
67
+ # all values are `untyped`.
68
+ class Params
69
+ def []: (Symbol key) -> untyped
70
+ def dig: (Symbol, *Symbol) -> untyped
71
+ def valid?: () -> bool
72
+ def errors: () -> Hash[Symbol, Array[String]]
73
+ def to_h: () -> Hash[Symbol, untyped]
74
+ def fetch: (Symbol key, ?untyped default) -> untyped
75
+ def slice: (*Symbol keys) -> Hash[Symbol, untyped]
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "rigor/type"
5
+ require "rigor/flow_contribution"
6
+ require "rigor/flow_contribution/fact"
7
+
8
+ module Rigor
9
+ module Plugin
10
+ class Minitest < Rigor::Plugin::Base
11
+ # Recognises the four shapes of Minitest / Test::Unit
12
+ # assertions and emits a `post_return_fact` that narrows
13
+ # the named local on the post-call edge.
14
+ #
15
+ # ## Recognised call shapes
16
+ #
17
+ # ### (1) Minitest / Test::Unit `assert_*` (positive) ###
18
+ #
19
+ # assert_kind_of(T, x) → narrow x to T
20
+ # assert_instance_of(T, x) → narrow x to T
21
+ # assert_nil(x) → narrow x to Constant<nil>
22
+ # assert_equal(literal, x) → narrow x to Constant<literal>
23
+ # assert_match(regex, x) → narrow x to String
24
+ #
25
+ # ### (2) Minitest / Test::Unit `refute_*` / `assert_not_*` (negative) ###
26
+ #
27
+ # refute_kind_of(T, x) → narrow x AWAY from T
28
+ # refute_instance_of(T, x) → narrow x AWAY from T
29
+ # refute_nil(x) → narrow x AWAY from nil
30
+ # refute_equal(literal, x) → narrow x AWAY from Constant<literal>
31
+ #
32
+ # Test::Unit's `assert_not_nil(x)` / `assert_not_equal(...)` /
33
+ # `assert_not_kind_of(...)` / `assert_not_instance_of(...)`
34
+ # share the recognizer with `refute_*` (they're aliases).
35
+ #
36
+ # ### (3) Minitest/spec `_(x).must_*` (positive) ###
37
+ #
38
+ # _(x).must_be_kind_of(T) → narrow x to T
39
+ # _(x).must_be_instance_of(T) → narrow x to T
40
+ # _(x).must_be_nil → narrow x to Constant<nil>
41
+ # _(x).must_equal(literal) → narrow x to Constant<literal>
42
+ # _(x).must_match(regex) → narrow x to String
43
+ #
44
+ # The legacy bare `x.must_be_kind_of(T)` form (Minitest <
45
+ # 6.0 monkey-patched onto Object) is intentionally NOT
46
+ # recognised — the receiver is the value itself rather
47
+ # than a wrapping `_(value)`, so the analyzer has nothing
48
+ # to narrow against. Users who still rely on the legacy
49
+ # form should migrate to `_(x).must_*`.
50
+ #
51
+ # ### (4) Minitest/spec `_(x).wont_*` (negative) ###
52
+ #
53
+ # _(x).wont_be_kind_of(T) → narrow x AWAY from T
54
+ # _(x).wont_be_nil → narrow x AWAY from nil
55
+ # _(x).wont_equal(literal) → narrow x AWAY from Constant<literal>
56
+ #
57
+ # ## Not yet recognised
58
+ #
59
+ # `assert_predicate(x, :foo?)` (custom predicate) /
60
+ # `assert_respond_to(x, :method)` / `assert_includes` /
61
+ # `assert_operator` / `assert_throws` /
62
+ # `assert_raises(T) { ... }` etc. — each either needs a
63
+ # carrier Rigor doesn't model today (predicate-state,
64
+ # respond-to-set) or a multi-edge fact that the
65
+ # `post_return_facts` slot can't express. Queued for
66
+ # follow-up slices.
67
+ module AssertionAnalyzer
68
+ module_function
69
+
70
+ # @param call_node [Prism::CallNode]
71
+ # @param environment [Rigor::Environment, nil]
72
+ # @return [Rigor::FlowContribution, nil]
73
+ def contribution_for(call_node, environment:)
74
+ return nil unless call_node.is_a?(Prism::CallNode)
75
+
76
+ fact =
77
+ assert_form_fact(call_node, environment: environment) ||
78
+ spec_form_fact(call_node, environment: environment)
79
+ return nil if fact.nil?
80
+
81
+ Rigor::FlowContribution.new(post_return_facts: [fact])
82
+ end
83
+
84
+ # --- assert_* / refute_* / assert_not_* form ---
85
+
86
+ # Maps each recognised assertion name to a tuple
87
+ # `[shape, negative]`. `shape` is one of:
88
+ #
89
+ # - :class_then_local — `assert_kind_of(T, x)`, T at
90
+ # args[0], local at args[1].
91
+ # - :nil_local — `assert_nil(x)`, local at args[0].
92
+ # - :literal_then_local — `assert_equal(literal, x)`,
93
+ # literal at args[0], local at args[1].
94
+ # - :regex_then_local — `assert_match(regex, x)`,
95
+ # regex at args[0], local at args[1].
96
+ ASSERT_FORM = {
97
+ assert_kind_of: [:class_then_local, false],
98
+ assert_instance_of: [:class_then_local, false],
99
+ refute_kind_of: [:class_then_local, true],
100
+ refute_instance_of: [:class_then_local, true],
101
+ assert_not_kind_of: [:class_then_local, true],
102
+ assert_not_instance_of: [:class_then_local, true],
103
+ assert_nil: [:nil_local, false],
104
+ refute_nil: [:nil_local, true],
105
+ assert_not_nil: [:nil_local, true],
106
+ assert_equal: [:literal_then_local, false],
107
+ refute_equal: [:literal_then_local, true],
108
+ assert_not_equal: [:literal_then_local, true],
109
+ assert_match: [:regex_then_local, false]
110
+ }.freeze
111
+ private_constant :ASSERT_FORM
112
+
113
+ def assert_form_fact(call_node, environment:)
114
+ return nil unless call_node.receiver.nil?
115
+
116
+ shape_negative = ASSERT_FORM[call_node.name]
117
+ return nil if shape_negative.nil?
118
+
119
+ shape, negative = shape_negative
120
+ args = call_node.arguments&.arguments || []
121
+ fact_for_shape(shape, args, negative: negative, environment: environment)
122
+ end
123
+
124
+ # --- _(x).must_* / .wont_* form ---
125
+
126
+ # Maps the spec-style matcher names to `[shape,
127
+ # negative]`. `shape`:
128
+ # - :class_arg — `_(x).must_be_kind_of(T)`, T at args[0].
129
+ # - :no_arg_nil — `_(x).must_be_nil`, no args.
130
+ # - :literal_arg — `_(x).must_equal(literal)`, literal at args[0].
131
+ # - :regex_arg — `_(x).must_match(regex)`, regex at args[0].
132
+ SPEC_MATCHER_FORM = {
133
+ must_be_kind_of: [:class_arg, false],
134
+ must_be_instance_of: [:class_arg, false],
135
+ must_be_a: [:class_arg, false],
136
+ must_be_an_instance_of: [:class_arg, false],
137
+ wont_be_kind_of: [:class_arg, true],
138
+ wont_be_instance_of: [:class_arg, true],
139
+ must_be_nil: [:no_arg_nil, false],
140
+ wont_be_nil: [:no_arg_nil, true],
141
+ must_equal: [:literal_arg, false],
142
+ wont_equal: [:literal_arg, true],
143
+ must_match: [:regex_arg, false]
144
+ }.freeze
145
+ private_constant :SPEC_MATCHER_FORM
146
+
147
+ def spec_form_fact(call_node, environment:)
148
+ shape_negative = SPEC_MATCHER_FORM[call_node.name]
149
+ return nil if shape_negative.nil?
150
+
151
+ target_local = spec_receiver_local(call_node)
152
+ return nil if target_local.nil?
153
+
154
+ shape, negative = shape_negative
155
+ args = call_node.arguments&.arguments || []
156
+ fact_for_spec_shape(shape, target_local, args, negative: negative, environment: environment)
157
+ end
158
+
159
+ # `_(x)` returns a `Minitest::Expectation` wrapping x.
160
+ # Some specs use `value(x)` or `expect(x)` interchangeably
161
+ # (Minitest provides all three as aliases). Recognises the
162
+ # local-variable arg in any of those receiver-call shapes.
163
+ SPEC_WRAPPER_NAMES = %i[_ value expect].freeze
164
+ private_constant :SPEC_WRAPPER_NAMES
165
+
166
+ def spec_receiver_local(matcher_call)
167
+ recv = matcher_call.receiver
168
+ return nil unless recv.is_a?(Prism::CallNode)
169
+ return nil unless recv.receiver.nil? && SPEC_WRAPPER_NAMES.include?(recv.name)
170
+
171
+ args = recv.arguments&.arguments || []
172
+ return nil unless args.size == 1
173
+ return nil unless args.first.is_a?(Prism::LocalVariableReadNode)
174
+
175
+ args.first.name
176
+ end
177
+
178
+ # --- shape resolvers ---
179
+
180
+ def fact_for_shape(shape, args, negative:, environment:)
181
+ case shape
182
+ when :class_then_local
183
+ return nil unless args.size == 2
184
+ return nil unless args[1].is_a?(Prism::LocalVariableReadNode)
185
+
186
+ type = nominal_type_for(args[0], environment: environment)
187
+ fact_for(args[1].name, type, negative: negative)
188
+ when :nil_local
189
+ return nil unless args.size == 1
190
+ return nil unless args[0].is_a?(Prism::LocalVariableReadNode)
191
+
192
+ fact_for(args[0].name, Rigor::Type::Combinator.constant_of(nil), negative: negative)
193
+ when :literal_then_local
194
+ return nil unless args.size == 2
195
+ return nil unless args[1].is_a?(Prism::LocalVariableReadNode)
196
+
197
+ literal = literal_value_for(args[0])
198
+ return nil if literal.equal?(NO_LITERAL)
199
+
200
+ fact_for(args[1].name, Rigor::Type::Combinator.constant_of(literal), negative: negative)
201
+ when :regex_then_local
202
+ return nil unless args.size == 2
203
+ return nil unless args[1].is_a?(Prism::LocalVariableReadNode)
204
+ return nil unless regex_literal?(args[0])
205
+
206
+ fact_for(args[1].name, Rigor::Type::Combinator.nominal_of("String"), negative: negative)
207
+ end
208
+ end
209
+
210
+ def fact_for_spec_shape(shape, target_local, args, negative:, environment:)
211
+ case shape
212
+ when :class_arg
213
+ return nil unless args.size == 1
214
+
215
+ type = nominal_type_for(args[0], environment: environment)
216
+ fact_for(target_local, type, negative: negative)
217
+ when :no_arg_nil
218
+ return nil unless args.empty?
219
+
220
+ fact_for(target_local, Rigor::Type::Combinator.constant_of(nil), negative: negative)
221
+ when :literal_arg
222
+ return nil unless args.size == 1
223
+
224
+ literal = literal_value_for(args[0])
225
+ return nil if literal.equal?(NO_LITERAL)
226
+
227
+ fact_for(target_local, Rigor::Type::Combinator.constant_of(literal), negative: negative)
228
+ when :regex_arg
229
+ return nil unless args.size == 1
230
+ return nil unless regex_literal?(args[0])
231
+
232
+ fact_for(target_local, Rigor::Type::Combinator.nominal_of("String"), negative: negative)
233
+ end
234
+ end
235
+
236
+ def fact_for(local_name, type, negative:)
237
+ return nil if type.nil?
238
+
239
+ Rigor::FlowContribution::Fact.new(
240
+ target_kind: :local,
241
+ target_name: local_name,
242
+ type: type,
243
+ negative: negative
244
+ )
245
+ end
246
+
247
+ # --- helpers shared with rigor-rspec MatcherAnalyzer ---
248
+
249
+ def nominal_type_for(node, environment:)
250
+ class_name = constant_path_name(node)
251
+ return nil if class_name.nil?
252
+
253
+ if environment
254
+ environment.nominal_for_name(class_name) ||
255
+ Rigor::Type::Combinator.nominal_of(class_name)
256
+ else
257
+ Rigor::Type::Combinator.nominal_of(class_name)
258
+ end
259
+ end
260
+
261
+ NO_LITERAL = Object.new.freeze
262
+ private_constant :NO_LITERAL
263
+
264
+ def literal_value_for(node)
265
+ case node
266
+ when Prism::IntegerNode then node.value
267
+ when Prism::FloatNode then node.value
268
+ when Prism::TrueNode then true
269
+ when Prism::FalseNode then false
270
+ when Prism::NilNode then nil
271
+ when Prism::StringNode then node.unescaped
272
+ when Prism::SymbolNode then node.unescaped.to_sym
273
+ else NO_LITERAL
274
+ end
275
+ end
276
+
277
+ def regex_literal?(node)
278
+ node.is_a?(Prism::RegularExpressionNode) ||
279
+ node.is_a?(Prism::InterpolatedRegularExpressionNode)
280
+ end
281
+
282
+ def constant_path_name(node)
283
+ case node
284
+ when Prism::ConstantReadNode
285
+ node.name.to_s
286
+ when Prism::ConstantPathNode
287
+ parts = []
288
+ current = node
289
+ while current.is_a?(Prism::ConstantPathNode)
290
+ parts.unshift(current.name.to_s)
291
+ current = current.parent
292
+ end
293
+ case current
294
+ when nil then "::#{parts.join('::')}"
295
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "minitest/assertion_analyzer"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ # rigor-minitest — narrows locals through Minitest /
10
+ # Test::Unit assertions.
11
+ #
12
+ # Recognises every supported `assert_*` / `refute_*` /
13
+ # `assert_not_*` shape (Minitest core + Test::Unit aliases),
14
+ # plus the Minitest/spec `_(x).must_*` / `.wont_*` matchers
15
+ # (`expect(x)` / `value(x)` wrappers covered too). For each
16
+ # recognised assertion the plugin emits a Rigor
17
+ # `post_return_fact` whose `:local`-kind target narrows the
18
+ # named local on the post-call edge so downstream calls in
19
+ # the same `it` / `test_` / `def test_*` body resolve
20
+ # against the narrowed type.
21
+ #
22
+ # ## Why a single plugin covers two frameworks
23
+ #
24
+ # Minitest and Test::Unit share the `assert_*` / `refute_*`
25
+ # surface (Test::Unit's `assert_not_*` aliases are
26
+ # recognised by the same rule table). The matchers_vaccine
27
+ # gem layers RSpec-style matcher composition on top of
28
+ # Minitest's `must` API and is covered by the spec-style
29
+ # recogniser without additional wiring.
30
+ #
31
+ # ## Configuration
32
+ #
33
+ # No knobs in v0.1.0. Activate via `plugins: ["rigor-minitest"]`
34
+ # in `.rigor.yml`.
35
+ #
36
+ # ## Limitations (v0.1.0)
37
+ #
38
+ # - **No `assert_raises(T) { ... }`** — that's a block-shape
39
+ # matcher and Rigor's narrowing model is for
40
+ # straight-line locals.
41
+ # - **No `assert_predicate(x, :foo?)`** — needs a
42
+ # predicate-state carrier Rigor doesn't model today.
43
+ # - **No `assert_respond_to(x, :m)`** — same shape gap
44
+ # (respond-to-set carrier).
45
+ # - **No legacy bare `x.must_be_kind_of(T)`** — the
46
+ # receiver IS the value, so the analyzer has nothing to
47
+ # narrow against. Users should migrate to `_(x).must_*`.
48
+ # - **No `assert_in_delta` / `assert_operator`** — float-
49
+ # range / generic operator narrowing is future work.
50
+ # - **No `assert_throws(:tag) { ... }` / `assert_raises`** —
51
+ # block-form expectations need a separate slice.
52
+ class Minitest < Rigor::Plugin::Base
53
+ manifest(
54
+ id: "minitest",
55
+ version: "0.1.0",
56
+ description: "Narrows locals through Minitest / Test::Unit `assert_*` / `refute_*` " \
57
+ "and Minitest/spec `_(x).must_*` / `.wont_*` matchers."
58
+ )
59
+
60
+ # Pillar 2 Slice 1 (rigor-minitest sibling) — emits
61
+ # `post_return_facts` for every recognised assertion. The
62
+ # engine routes `:local`-kind facts through
63
+ # `StatementEvaluator#apply_local_post_return_fact` (added
64
+ # in v0.1.8 for the rigor-rspec slice).
65
+ def flow_contribution_for(call_node:, scope:)
66
+ AssertionAnalyzer.contribution_for(call_node, environment: scope&.environment)
67
+ end
68
+ end
69
+
70
+ Rigor::Plugin.register(Minitest)
71
+ end
72
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/minitest"