rigortype 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules.rb +428 -6
  5. data/lib/rigor/analysis/diagnostic.rb +55 -3
  6. data/lib/rigor/analysis/rule_catalog.rb +80 -0
  7. data/lib/rigor/analysis/runner.rb +71 -2
  8. data/lib/rigor/analysis/worker_session.rb +3 -2
  9. data/lib/rigor/cache/descriptor.rb +6 -2
  10. data/lib/rigor/cli/plugin_command.rb +245 -0
  11. data/lib/rigor/cli/plugins_command.rb +51 -4
  12. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  13. data/lib/rigor/cli.rb +143 -5
  14. data/lib/rigor/configuration/severity_profile.rb +9 -0
  15. data/lib/rigor/environment/rbs_loader.rb +259 -1
  16. data/lib/rigor/environment.rb +8 -2
  17. data/lib/rigor/inference/budget_trace.rb +137 -0
  18. data/lib/rigor/inference/expression_typer.rb +9 -2
  19. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  20. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  21. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
  22. data/lib/rigor/inference/method_dispatcher.rb +57 -10
  23. data/lib/rigor/inference/precision_scanner.rb +60 -1
  24. data/lib/rigor/inference/scope_indexer.rb +184 -27
  25. data/lib/rigor/inference/statement_evaluator.rb +13 -8
  26. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  27. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  28. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  29. data/lib/rigor/plugin/base.rb +321 -2
  30. data/lib/rigor/plugin/box.rb +64 -0
  31. data/lib/rigor/plugin/inflector.rb +121 -0
  32. data/lib/rigor/plugin/isolation.rb +191 -0
  33. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  34. data/lib/rigor/plugin/macro.rb +1 -0
  35. data/lib/rigor/plugin/manifest.rb +120 -23
  36. data/lib/rigor/plugin/node_context.rb +62 -0
  37. data/lib/rigor/plugin/registry.rb +10 -0
  38. data/lib/rigor/plugin.rb +3 -0
  39. data/lib/rigor/scope.rb +27 -1
  40. data/lib/rigor/sig_gen/generator.rb +2 -3
  41. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  42. data/lib/rigor/source/literals.rb +118 -0
  43. data/lib/rigor/source/node_walker.rb +26 -0
  44. data/lib/rigor/source.rb +1 -0
  45. data/lib/rigor/triage/catalogue.rb +71 -5
  46. data/lib/rigor/type/combinator.rb +6 -1
  47. data/lib/rigor/type/union.rb +65 -1
  48. data/lib/rigor/version.rb +1 -1
  49. data/lib/rigor.rb +1 -0
  50. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  51. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  52. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  53. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  54. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  55. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  56. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  57. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  58. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  62. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  63. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  66. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  67. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  68. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  69. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  70. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  71. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  72. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  73. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  74. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  75. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  76. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  77. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  78. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  79. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  80. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  81. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  82. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  83. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  84. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
  85. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  86. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  87. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  88. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  89. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  90. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  91. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  92. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  93. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  94. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  95. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  96. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  97. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  98. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  99. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  100. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  101. data/sig/rigor/plugin/base.rbs +58 -3
  102. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  103. data/sig/rigor/plugin/manifest.rbs +31 -1
  104. data/sig/rigor/scope.rbs +3 -0
  105. data/sig/rigor/source.rbs +12 -0
  106. data/sig/rigor.rbs +5 -0
  107. data/skills/rigor-plugin-author/SKILL.md +33 -9
  108. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +65 -26
  109. data/skills/rigor-plugin-author/references/02-walker-and-types.md +213 -80
  110. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  111. data/skills/rigor-project-init/SKILL.md +72 -7
  112. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +233 -19
  113. metadata +53 -2
  114. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
data/lib/rigor/scope.rb CHANGED
@@ -20,7 +20,7 @@ module Rigor
20
20
  :ivars, :cvars, :globals,
21
21
  :class_ivars, :class_cvars, :program_globals,
22
22
  :discovered_classes, :in_source_constants, :discovered_methods,
23
- :discovered_def_nodes, :discovered_method_visibilities,
23
+ :discovered_def_nodes, :discovered_def_sources, :discovered_method_visibilities,
24
24
  :discovered_superclasses, :discovered_includes,
25
25
  :indexed_narrowings, :method_chain_narrowings,
26
26
  :source_path
@@ -89,6 +89,7 @@ module Rigor
89
89
  in_source_constants: EMPTY_VAR_BINDINGS,
90
90
  discovered_methods: EMPTY_CLASS_BINDINGS,
91
91
  discovered_def_nodes: EMPTY_CLASS_BINDINGS,
92
+ discovered_def_sources: EMPTY_CLASS_BINDINGS,
92
93
  discovered_method_visibilities: EMPTY_CLASS_BINDINGS,
93
94
  discovered_superclasses: EMPTY_CLASS_BINDINGS,
94
95
  discovered_includes: EMPTY_CLASS_BINDINGS,
@@ -111,6 +112,7 @@ module Rigor
111
112
  @in_source_constants = in_source_constants
112
113
  @discovered_methods = discovered_methods
113
114
  @discovered_def_nodes = discovered_def_nodes
115
+ @discovered_def_sources = discovered_def_sources
114
116
  @discovered_method_visibilities = discovered_method_visibilities
115
117
  @discovered_superclasses = discovered_superclasses
116
118
  @discovered_includes = discovered_includes
@@ -361,6 +363,27 @@ module Rigor
361
363
  rebuild(discovered_def_nodes: table)
362
364
  end
363
365
 
366
+ # Companion to {#user_def_for}: returns the `"path:line"` where
367
+ # the project defines `class_name#method_name` (instance-side),
368
+ # or nil. Populated only by the cross-file project pre-pass
369
+ # ({Inference::ScopeIndexer.discovered_def_index_for_paths}) — a
370
+ # `Prism::Location` hides its source file, so the site is recorded
371
+ # at scan time. `CheckRules#undefined_method_diagnostic` consults
372
+ # this to name the defining file when a project monkey-patch on a
373
+ # core/stdlib/gem class is called cross-file, so the diagnostic
374
+ # can point at `pre_eval:` (ADR-17) instead of reading as a bare
375
+ # unresolved call.
376
+ def user_def_site_for(class_name, method_name)
377
+ table = @discovered_def_sources[class_name.to_s]
378
+ return nil unless table
379
+
380
+ table[method_name.to_sym]
381
+ end
382
+
383
+ def with_discovered_def_sources(table)
384
+ rebuild(discovered_def_sources: table)
385
+ end
386
+
364
387
  # ADR-24 slice 2 — per-class table mapping a fully
365
388
  # qualified user-class name to its superclass name AS
366
389
  # WRITTEN at the `class Foo < Bar` declaration (`"Bar"`,
@@ -558,6 +581,7 @@ module Rigor
558
581
  class_ivars: @class_ivars, class_cvars: @class_cvars, program_globals: @program_globals,
559
582
  discovered_classes: @discovered_classes, in_source_constants: @in_source_constants,
560
583
  discovered_methods: @discovered_methods, discovered_def_nodes: @discovered_def_nodes,
584
+ discovered_def_sources: @discovered_def_sources,
561
585
  discovered_method_visibilities: @discovered_method_visibilities,
562
586
  discovered_superclasses: @discovered_superclasses,
563
587
  discovered_includes: @discovered_includes,
@@ -576,6 +600,7 @@ module Rigor
576
600
  in_source_constants: in_source_constants,
577
601
  discovered_methods: discovered_methods,
578
602
  discovered_def_nodes: discovered_def_nodes,
603
+ discovered_def_sources: discovered_def_sources,
579
604
  discovered_method_visibilities: discovered_method_visibilities,
580
605
  discovered_superclasses: discovered_superclasses,
581
606
  discovered_includes: discovered_includes,
@@ -607,6 +632,7 @@ module Rigor
607
632
  in_source_constants: in_source_constants,
608
633
  discovered_methods: discovered_methods,
609
634
  discovered_def_nodes: discovered_def_nodes,
635
+ discovered_def_sources: discovered_def_sources,
610
636
  discovered_method_visibilities: discovered_method_visibilities,
611
637
  discovered_superclasses: discovered_superclasses,
612
638
  discovered_includes: discovered_includes,
@@ -7,6 +7,7 @@ require_relative "../environment"
7
7
  require_relative "../scope"
8
8
  require_relative "../reflection"
9
9
  require_relative "../type"
10
+ require_relative "../source/literals"
10
11
  require_relative "../inference/def_return_typer"
11
12
  require_relative "../inference/scope_indexer"
12
13
  require_relative "../inference/rbs_type_translator"
@@ -893,9 +894,7 @@ module Rigor
893
894
  end
894
895
 
895
896
  def extract_symbol_arguments(call_node)
896
- (call_node.arguments&.arguments || []).filter_map do |arg|
897
- arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
898
- end
897
+ Source::Literals.symbol_arguments(call_node)
899
898
  end
900
899
 
901
900
  # Returns a closure that looks up `:@<attr_name>` in the
@@ -5,6 +5,7 @@ require "prism"
5
5
  require_relative "../environment"
6
6
  require_relative "../scope"
7
7
  require_relative "../type"
8
+ require_relative "../source/literals"
8
9
  require_relative "../inference/scope_indexer"
9
10
 
10
11
  module Rigor
@@ -307,9 +308,8 @@ module Rigor
307
308
  def binding_name_for(call_node)
308
309
  first_arg = call_node.arguments&.arguments&.first
309
310
  return call_node.name == :subject ? :subject : nil if first_arg.nil?
310
- return first_arg.unescaped.to_sym if first_arg.is_a?(Prism::SymbolNode) || first_arg.is_a?(Prism::StringNode)
311
311
 
312
- nil
312
+ Source::Literals.symbol_or_string(first_arg)
313
313
  end
314
314
 
315
315
  def type_block_body(block_node, scope_index)
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Source
7
+ # Extracts literal Symbol/String values from Prism call arguments.
8
+ #
9
+ # The "is this argument a literal `:sym` or `"str"`, and if so what
10
+ # Symbol does it name?" question recurs across the analyzer (sig-gen
11
+ # observation, attr-accessor generation, synthetic-method scanning)
12
+ # and across nearly every DSL plugin (`state :draft`,
13
+ # `has_one_attached :avatar`, `validate_presence_of(:name)`, …). This
14
+ # module is the one place that answers it, so the
15
+ # `node.unescaped.to_sym if SymbolNode || StringNode` shape is written
16
+ # once rather than copied per call site.
17
+ #
18
+ # `#unescaped` (not `#value`) is used deliberately so an interpolation-
19
+ # free `"foo"` / `:foo` round-trips to `:foo` consistently for both
20
+ # node kinds.
21
+ #
22
+ # The surface is a small grid over two axes — which node kinds are
23
+ # accepted (`SymbolNode` only, or `SymbolNode`/`StringNode`) and what
24
+ # the caller wants back (the interned `Symbol`, or the raw `String`
25
+ # name). The SymbolNode-only forms ({.symbol} / {.symbol_name}) exist
26
+ # so a DSL that distinguishes `state :draft` from `state "draft"`
27
+ # keeps that distinction instead of silently widening to accept the
28
+ # string literal.
29
+ #
30
+ # | accepts | → Symbol | → String |
31
+ # | ------------------ | ------------------- | ------------------------ |
32
+ # | `:sym` only | {.symbol} | {.symbol_name} |
33
+ # | `:sym` or `"str"` | {.symbol_or_string} | {.symbol_or_string_name} |
34
+ module Literals
35
+ module_function
36
+
37
+ # The Symbol a literal `Prism::SymbolNode` / `Prism::StringNode`
38
+ # names, or `nil` for any other node (including `nil`).
39
+ #
40
+ # @param node [Prism::Node, nil]
41
+ # @return [Symbol, nil]
42
+ def symbol_or_string(node)
43
+ return nil unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
44
+
45
+ node.unescaped.to_sym
46
+ end
47
+
48
+ # The String a literal `Prism::SymbolNode` / `Prism::StringNode`
49
+ # names, or `nil` for any other node (including `nil`). The
50
+ # String-returning sibling of {.symbol_or_string} — for callers
51
+ # that key on the raw name rather than the interned Symbol (route
52
+ # helpers, factory names, filter targets). `#unescaped` round-trips
53
+ # an interpolation-free `:foo` / `"foo"` to `"foo"` for both kinds.
54
+ #
55
+ # @param node [Prism::Node, nil]
56
+ # @return [String, nil]
57
+ def symbol_or_string_name(node)
58
+ return nil unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
59
+
60
+ node.unescaped
61
+ end
62
+
63
+ # The Symbol a literal `Prism::SymbolNode` names, or `nil` for any
64
+ # other node (including a `Prism::StringNode` and `nil`). Stricter
65
+ # than {.symbol_or_string}: a DSL that accepts only `:draft` and
66
+ # not `"draft"` keeps that distinction by reaching for this rather
67
+ # than the Symbol-or-String form.
68
+ #
69
+ # @param node [Prism::Node, nil]
70
+ # @return [Symbol, nil]
71
+ def symbol(node)
72
+ return nil unless node.is_a?(Prism::SymbolNode)
73
+
74
+ node.unescaped.to_sym
75
+ end
76
+
77
+ # The String a literal `Prism::SymbolNode` names, or `nil` for any
78
+ # other node (including a `Prism::StringNode` and `nil`). The
79
+ # String-returning sibling of {.symbol} — SymbolNode-only, but the
80
+ # caller wants the raw name rather than the interned Symbol.
81
+ #
82
+ # @param node [Prism::Node, nil]
83
+ # @return [String, nil]
84
+ def symbol_name(node)
85
+ return nil unless node.is_a?(Prism::SymbolNode)
86
+
87
+ node.unescaped
88
+ end
89
+
90
+ # Every literal Symbol/String positional argument of a call, in
91
+ # source order. Non-literal arguments are dropped. Returns `[]` when
92
+ # the call has no argument list.
93
+ #
94
+ # @param call_node [Prism::CallNode, nil]
95
+ # @return [Array<Symbol>]
96
+ def symbol_arguments(call_node)
97
+ args = call_node&.arguments&.arguments
98
+ return [] if args.nil?
99
+
100
+ args.filter_map { |arg| symbol_or_string(arg) }
101
+ end
102
+
103
+ # The literal Symbol/String at positional `index`, or `nil` when the
104
+ # call has no argument list, the index is out of range, or the
105
+ # argument there is not a literal Symbol/String.
106
+ #
107
+ # @param call_node [Prism::CallNode, nil]
108
+ # @param index [Integer]
109
+ # @return [Symbol, nil]
110
+ def symbol_arg(call_node, index)
111
+ args = call_node&.arguments&.arguments
112
+ return nil if args.nil?
113
+
114
+ symbol_or_string(args[index])
115
+ end
116
+ end
117
+ end
118
+ end
@@ -32,6 +32,32 @@ module Rigor
32
32
  yield node
33
33
  node.compact_child_nodes.each { |child| walk(child, &) }
34
34
  end
35
+
36
+ # Like {.each}, but also yields the node's lexical ancestor chain
37
+ # (outermost first, EXCLUDING the node itself). The yielded
38
+ # `ancestors` array is the live descent stack — callers that retain
39
+ # it past the block invocation MUST copy it (`Plugin::NodeContext`
40
+ # does). Used by the plugin engine to give `node_rule` blocks their
41
+ # enclosing class / method / block context (ADR-37 slice 1d).
42
+ #
43
+ # @yieldparam node [Prism::Node]
44
+ # @yieldparam ancestors [Array<Prism::Node>]
45
+ # @return [Enumerator] when no block is given.
46
+ def each_with_ancestors(root, &)
47
+ return to_enum(__method__, root) unless block_given?
48
+
49
+ walk_with_ancestors(root, [], &)
50
+ nil
51
+ end
52
+
53
+ def walk_with_ancestors(node, ancestors, &block)
54
+ return unless node.is_a?(Prism::Node)
55
+
56
+ block.call(node, ancestors)
57
+ ancestors.push(node)
58
+ node.compact_child_nodes.each { |child| walk_with_ancestors(child, ancestors, &block) }
59
+ ancestors.pop
60
+ end
35
61
  end
36
62
  end
37
63
  end
data/lib/rigor/source.rb CHANGED
@@ -13,3 +13,4 @@ end
13
13
 
14
14
  require_relative "source/node_locator"
15
15
  require_relative "source/node_walker"
16
+ require_relative "source/literals"
@@ -22,6 +22,7 @@ module Rigor
22
22
  module_function
23
23
 
24
24
  UNDEFINED_METHOD_RULE = "call.undefined-method"
25
+ UNRESOLVED_TOPLEVEL_RULE = "call.unresolved-toplevel"
25
26
 
26
27
  # `undefined method `foo' for <receiver>`
27
28
  UNDEF_METHOD = /\Aundefined method [`'"]([^`'"]+)['"`] for (.+)\z/
@@ -104,9 +105,19 @@ module Rigor
104
105
  # monkey-patch): a known AR method on `Array[...]` deserves
105
106
  # the precise relation-misinference hint, not the generic
106
107
  # "project core-ext" guess H2 would otherwise claim it for.
108
+ #
109
+ # H2K (known project patch) runs before the generic H2: the
110
+ # engine has already proved the defining site via the
111
+ # `project_definition_site` field (ADR-17), so those
112
+ # diagnostics get the high-confidence file-naming hint rather
113
+ # than the spread-based guess. H7 (unresolved toplevel) runs
114
+ # before the systemic / genuine-bug catch-alls so toplevel
115
+ # resolution misses route to `pre_eval:` (ADR-34) instead of
116
+ # reading as scattered bugs.
107
117
  def recognisers
108
118
  %i[h1_activesupport h4_ar_relation h3_gem_without_rbs
109
- h2_monkey_patch h5_systemic_cluster h6_genuine_bugs]
119
+ h2k_known_project_patch h2_monkey_patch h7_unresolved_toplevel
120
+ h5_systemic_cluster h6_genuine_bugs]
110
121
  end
111
122
 
112
123
  # --- H1 — likely ActiveSupport core_ext --------------------
@@ -127,6 +138,31 @@ module Rigor
127
138
  ), matched]
128
139
  end
129
140
 
141
+ # --- H2K — known project monkey-patch (engine-proven) ------
142
+ # ADR-17 / WD3 slice 4: the `call.undefined-method` rule sets
143
+ # `project_definition_site` when the project itself defines the
144
+ # called method on the receiver class somewhere in the file set
145
+ # (a reopened core/stdlib/gem class the dispatcher does not
146
+ # apply cross-file). That is direct evidence — not a spread
147
+ # heuristic — so this recogniser is `:likely` and names the
148
+ # defining files outright. It runs before the generic H2.
149
+ def h2k_known_project_patch(pool)
150
+ matched = pool.select(&:project_definition_site)
151
+ return nil if matched.empty?
152
+
153
+ files = matched.map { |d| d.project_definition_site.sub(/:\d+\z/, "") }
154
+ .uniq.sort
155
+ [Hint.new(
156
+ id: "project-monkey-patch-known", confidence: :likely,
157
+ diagnostic_count: matched.size,
158
+ summary: "#{matched.size} undefined-method site(s) resolve to project " \
159
+ "definitions in #{files.first(3).join(', ')} — reopened core/" \
160
+ "stdlib/gem classes Rigor does not apply cross-file",
161
+ action: "List #{files.size == 1 ? 'this file' : 'these files'} in " \
162
+ "`.rigor.yml`'s `pre_eval:` (ADR-17): #{files.join(', ')}"
163
+ ), matched]
164
+ end
165
+
130
166
  # --- H2 — likely a project monkey-patch / refinement -------
131
167
  def h2_monkey_patch(pool)
132
168
  groups = undefined_method_groups(pool).select do |(_method, _recv), diags|
@@ -179,6 +215,30 @@ module Rigor
179
215
  ), matched]
180
216
  end
181
217
 
218
+ # --- H7 — unresolved toplevel implicit-self calls ----------
219
+ # ADR-34: `call.unresolved-toplevel` fires on a toplevel
220
+ # implicit-self call (no receiver, outside any def / class /
221
+ # module) that resolves against no visible contributor. The
222
+ # canonical opt-out is `pre_eval:` — the file is usually a
223
+ # script relying on methods defined by a monkey-patch or a
224
+ # required helper Rigor did not walk. Grouped, not per-site,
225
+ # so the report names the cluster once.
226
+ def h7_unresolved_toplevel(pool)
227
+ matched = pool.select { |d| rule_of(d) == UNRESOLVED_TOPLEVEL_RULE }
228
+ return nil if matched.empty?
229
+
230
+ files = matched.map(&:path).uniq.sort
231
+ [Hint.new(
232
+ id: "unresolved-toplevel", confidence: :possible,
233
+ diagnostic_count: matched.size,
234
+ summary: "#{matched.size} toplevel call(s) resolve to nothing visible " \
235
+ "across #{files.size} file(s) (#{top_methods(matched, parser: :toplevel)})",
236
+ action: "If a monkey-patch or required helper defines these, list its " \
237
+ "file in `.rigor.yml`'s `pre_eval:` (ADR-17); otherwise they may " \
238
+ "be genuine typos or missing requires."
239
+ ), matched]
240
+ end
241
+
182
242
  # --- H5 — systemic single-file cluster ---------------------
183
243
  def h5_systemic_cluster(pool)
184
244
  bucket = pool.group_by { |d| [d.path, rule_of(d)] }
@@ -282,10 +342,16 @@ module Rigor
282
342
  groups.keys.first(3).map { |method, recv| "`#{method}` on #{recv}" }.join(", ")
283
343
  end
284
344
 
285
- def top_methods(diagnostics, limit: 5)
286
- diagnostics.filter_map { |d| parse_undefined_method(d)&.fetch(:method) }
287
- .tally.sort_by { |method, count| [-count, method] }
288
- .first(limit).map { |method, count| "#{method}×#{count}" }.join(" ")
345
+ # `parser: :undefined_method` (default) reads the method from
346
+ # the parsed `undefined-method` shape; `parser: :toplevel`
347
+ # reads the structured `method_name` field directly (the
348
+ # `unresolved-toplevel` rule carries no receiver to parse).
349
+ def top_methods(diagnostics, limit: 5, parser: :undefined_method)
350
+ names = diagnostics.filter_map do |d|
351
+ parser == :toplevel ? d.method_name : parse_undefined_method(d)&.fetch(:method)
352
+ end
353
+ names.tally.sort_by { |method, count| [-count, method] }
354
+ .first(limit).map { |method, count| "#{method}×#{count}" }.join(" ")
289
355
  end
290
356
 
291
357
  def rule_of(diag)
@@ -14,6 +14,7 @@ require_relative "difference"
14
14
  require_relative "refined"
15
15
  require_relative "intersection"
16
16
  require_relative "bound_method"
17
+ require_relative "../inference/budget_trace"
17
18
 
18
19
  module Rigor
19
20
  module Type
@@ -360,7 +361,11 @@ module Rigor
360
361
  # Normalized union. Flattens nested Unions, deduplicates structurally
361
362
  # equal members, drops Bot, and collapses 0/1-member results.
362
363
  def union(*types)
363
- collapse_union(normalized_union_members(types))
364
+ result = collapse_union(normalized_union_members(types))
365
+ if Inference::BudgetTrace.enabled? && result.is_a?(Union)
366
+ Inference::BudgetTrace.observe(Inference::BudgetTrace::UNION_ARITY, result.members.size)
367
+ end
368
+ result
364
369
  end
365
370
 
366
371
  # `key_of[T]` type function — projects the type-level
@@ -24,8 +24,31 @@ module Rigor
24
24
  freeze
25
25
  end
26
26
 
27
+ # Display-only adoption of two concise RBS spellings for the
28
+ # union (see docs/type-specification/normalization.md § "Interaction
29
+ # with display" and rbs-compatible-types.md § "Optionals"). Both are
30
+ # purely cosmetic: `@members` keeps every carrier verbatim, so the
31
+ # underlying type identity, RBS erasure, and round-trip are unchanged
32
+ # — only the human-facing rendering reads like the RBS the user wrote.
33
+ #
34
+ # * `true | false` → `bool` (the RBS boolean alias). The
35
+ # `bool` token leads the rendering, so `false | Foo | true` reads
36
+ # as `bool | Foo` rather than burying the pair mid-list.
37
+ # * `T | nil` → `T?` (the RBS optional sugar). Only
38
+ # applied when exactly one *logical* member remains beside `nil`,
39
+ # matching the rbs gem's own `to_s`: a multi-member union such as
40
+ # `Integer | String | nil` stays explicit rather than gaining a
41
+ # parenthesised `(Integer | String)?`. The two collapses compose,
42
+ # so `false | true | nil` reads as `bool?`.
27
43
  def describe(verbosity = :short)
28
- members.map { |m| m.describe(verbosity) }.join(" | ")
44
+ return "#{optional_inner(verbosity)}?" if optional?
45
+
46
+ if boolean_pair?
47
+ rest = members.reject { |m| boolean_literal?(m) }
48
+ ["bool", *rest.map { |m| m.describe(verbosity) }].join(" | ")
49
+ else
50
+ members.map { |m| m.describe(verbosity) }.join(" | ")
51
+ end
29
52
  end
30
53
 
31
54
  # ADR-1 § "RBS round-trip is lossless" + the value-lattice
@@ -79,6 +102,47 @@ module Rigor
79
102
  def inspect
80
103
  "#<Rigor::Type::Union #{describe(:short)}>"
81
104
  end
105
+
106
+ private
107
+
108
+ # Both `true` and `false` literals are present, so the pair can
109
+ # render as `bool`. A union carrying only one of them stays a
110
+ # plain literal (`true` / `false`) — that asymmetry is meaningful.
111
+ def boolean_pair?
112
+ members.any? { |m| boolean_literal?(m, true) } &&
113
+ members.any? { |m| boolean_literal?(m, false) }
114
+ end
115
+
116
+ def boolean_literal?(member, which = :either)
117
+ return false unless member.is_a?(Constant)
118
+
119
+ case which
120
+ when :either then member.value.equal?(true) || member.value.equal?(false)
121
+ else member.value.equal?(which)
122
+ end
123
+ end
124
+
125
+ def nil_literal?(member)
126
+ member.is_a?(Constant) && member.value.nil?
127
+ end
128
+
129
+ # `nil` is present and, once the `bool` pair is treated as a
130
+ # single logical member, exactly one non-`nil` member remains —
131
+ # so the whole union renders as `T?`. Counting the bool pair as
132
+ # one is what lets `false | true | nil` reach `bool?`.
133
+ def optional?
134
+ return false unless members.any? { |m| nil_literal?(m) }
135
+
136
+ significant = members.reject { |m| nil_literal?(m) }
137
+ logical = significant.size - (boolean_pair? ? 1 : 0)
138
+ logical == 1
139
+ end
140
+
141
+ def optional_inner(verbosity)
142
+ return "bool" if boolean_pair?
143
+
144
+ members.find { |m| !nil_literal?(m) }.describe(verbosity)
145
+ end
82
146
  end
83
147
  end
84
148
  end
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.1.14"
4
+ VERSION = "0.1.16"
5
5
  end
data/lib/rigor.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "rigor/ast"
9
9
  require_relative "rigor/environment"
10
10
  require_relative "rigor/rbs_extended"
11
11
  require_relative "rigor/testing"
12
+ require_relative "rigor/inference/budget_trace"
12
13
  require_relative "rigor/inference/fallback"
13
14
  require_relative "rigor/inference/fallback_tracer"
14
15
  require_relative "rigor/inference/acceptance"
@@ -33,35 +33,31 @@ module Rigor
33
33
  ::ActionCable.server
34
34
  ].freeze
35
35
 
36
- Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
36
+ # One broadcast observation. Carries no path/location the
37
+ # caller (the `node_rule` block) positions it via
38
+ # `Plugin::Base#diagnostic`.
39
+ Violation = Struct.new(:rule, :severity, :message, keyword_init: true)
37
40
 
38
41
  module_function
39
42
 
40
- # @param path [String]
41
- # @param root [Prism::Node]
43
+ # The broadcast violations for a single call node, or `[]` when
44
+ # the node is not a `broadcast_to` / `ActionCable.server.broadcast`
45
+ # call this plugin recognises. ADR-37: the engine owns the walk.
46
+ #
47
+ # @param call_node [Prism::Node]
42
48
  # @param channel_index [ChannelIndex]
43
- # @return [Array<Diagnostic>]
44
- def diagnose(path:, root:, channel_index:)
45
- diagnostics = []
46
- walk(root) do |call_node|
47
- case call_node.name
48
- when :broadcast_to
49
- diagnostics.concat(analyse_broadcast_to(path, call_node, channel_index))
50
- when :broadcast
51
- diagnostics.concat(analyse_server_broadcast(path, call_node, channel_index))
52
- end
49
+ # @return [Array<Violation>]
50
+ def violations_for(call_node:, channel_index:)
51
+ return [] unless call_node.is_a?(Prism::CallNode)
52
+
53
+ case call_node.name
54
+ when :broadcast_to then analyse_broadcast_to(call_node, channel_index)
55
+ when :broadcast then analyse_server_broadcast(call_node, channel_index)
56
+ else []
53
57
  end
54
- diagnostics
55
58
  end
56
59
 
57
- def walk(node, &)
58
- return unless node.is_a?(Prism::Node)
59
-
60
- yield node if node.is_a?(Prism::CallNode)
61
- node.compact_child_nodes.each { |child| walk(child, &) }
62
- end
63
-
64
- def analyse_broadcast_to(path, call_node, channel_index)
60
+ def analyse_broadcast_to(call_node, channel_index)
65
61
  class_name = constant_receiver_name(call_node.receiver)
66
62
  return [] if class_name.nil?
67
63
 
@@ -72,12 +68,12 @@ module Rigor
72
68
  return [] unless class_name.end_with?("Channel")
73
69
 
74
70
  entry = channel_index.find(class_name) || channel_index.find("::#{class_name}")
75
- return [unknown_channel_diagnostic(path, call_node, class_name, channel_index)] if entry.nil?
71
+ return [unknown_channel_violation(class_name, channel_index)] if entry.nil?
76
72
 
77
- [broadcast_target_info(path, call_node, entry)]
73
+ [broadcast_target_info(entry)]
78
74
  end
79
75
 
80
- def analyse_server_broadcast(path, call_node, channel_index)
76
+ def analyse_server_broadcast(call_node, channel_index)
81
77
  receiver_path = call_chain_string(call_node.receiver)
82
78
  return [] unless SERVER_BROADCAST_RECEIVER_NAMES.include?(receiver_path)
83
79
 
@@ -88,60 +84,42 @@ module Rigor
88
84
  return [] if channel_index.any_dynamic_streams?
89
85
 
90
86
  stream_name = stream_arg.unescaped
91
- if channel_index.all_stream_names.include?(stream_name)
92
- return [server_broadcast_info(path, call_node, stream_name)]
93
- end
87
+ return [server_broadcast_info(stream_name)] if channel_index.all_stream_names.include?(stream_name)
94
88
 
95
- [unknown_stream_diagnostic(path, call_node, stream_name, channel_index)]
89
+ [unknown_stream_violation(stream_name, channel_index)]
96
90
  end
97
91
 
98
- def broadcast_target_info(path, call_node, entry)
99
- location = call_node.location
100
- Diagnostic.new(
101
- path: path,
102
- line: location.start_line,
103
- column: location.start_column + 1,
92
+ def broadcast_target_info(entry)
93
+ Violation.new(
104
94
  severity: :info,
105
95
  rule: "broadcast-target",
106
96
  message: "`#{entry.class_name}.broadcast_to(...)` matches discovered channel"
107
97
  )
108
98
  end
109
99
 
110
- def server_broadcast_info(path, call_node, stream_name)
111
- location = call_node.location
112
- Diagnostic.new(
113
- path: path,
114
- line: location.start_line,
115
- column: location.start_column + 1,
100
+ def server_broadcast_info(stream_name)
101
+ Violation.new(
116
102
  severity: :info,
117
103
  rule: "broadcast-stream",
118
104
  message: "`broadcast(\"#{stream_name}\", ...)` matches a registered `stream_from`"
119
105
  )
120
106
  end
121
107
 
122
- def unknown_channel_diagnostic(path, call_node, class_name, channel_index)
123
- location = call_node.location
108
+ def unknown_channel_violation(class_name, channel_index)
124
109
  suggestions = DidYouMean::SpellChecker.new(dictionary: channel_index.names).correct(class_name)
125
110
  suggestion_part = suggestions.empty? ? "" : " (did you mean `#{suggestions.first}`?)"
126
- Diagnostic.new(
127
- path: path,
128
- line: location.start_line,
129
- column: location.start_column + 1,
111
+ Violation.new(
130
112
  severity: :error,
131
113
  rule: "unknown-channel",
132
114
  message: "no ActionCable channel `#{class_name}`#{suggestion_part}"
133
115
  )
134
116
  end
135
117
 
136
- def unknown_stream_diagnostic(path, call_node, stream_name, channel_index)
137
- location = call_node.location
118
+ def unknown_stream_violation(stream_name, channel_index)
138
119
  dictionary = channel_index.all_stream_names.to_a
139
120
  suggestions = DidYouMean::SpellChecker.new(dictionary: dictionary).correct(stream_name)
140
121
  suggestion_part = suggestions.empty? ? "" : " (did you mean `\"#{suggestions.first}\"`?)"
141
- Diagnostic.new(
142
- path: path,
143
- line: location.start_line,
144
- column: location.start_column + 1,
122
+ Violation.new(
145
123
  severity: :warning,
146
124
  rule: "unknown-stream",
147
125
  message: "no `stream_from \"#{stream_name}\"` registration in any discovered " \