rigortype 0.1.17 → 0.1.19

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -222
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
  9. data/lib/rigor/analysis/check_rules.rb +275 -44
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
  12. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  13. data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
  14. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  15. data/lib/rigor/analysis/runner.rb +207 -1200
  16. data/lib/rigor/analysis/worker_session.rb +60 -11
  17. data/lib/rigor/bleeding_edge.rb +123 -0
  18. data/lib/rigor/cache/descriptor.rb +86 -8
  19. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  20. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  21. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  22. data/lib/rigor/cache/store.rb +46 -13
  23. data/lib/rigor/cli/annotate_command.rb +100 -15
  24. data/lib/rigor/cli/check_command.rb +708 -0
  25. data/lib/rigor/cli/ci_detector.rb +94 -0
  26. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  27. data/lib/rigor/cli/plugins_command.rb +2 -4
  28. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  29. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  30. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  31. data/lib/rigor/cli/trace_command.rb +143 -0
  32. data/lib/rigor/cli/trace_renderer.rb +310 -0
  33. data/lib/rigor/cli/triage_command.rb +6 -3
  34. data/lib/rigor/cli/triage_renderer.rb +15 -1
  35. data/lib/rigor/cli.rb +21 -612
  36. data/lib/rigor/configuration/severity_profile.rb +13 -1
  37. data/lib/rigor/configuration.rb +66 -7
  38. data/lib/rigor/environment/rbs_loader.rb +78 -68
  39. data/lib/rigor/environment.rb +1 -1
  40. data/lib/rigor/inference/acceptance.rb +10 -0
  41. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  42. data/lib/rigor/inference/budget_trace.rb +29 -2
  43. data/lib/rigor/inference/expression_typer.rb +1080 -105
  44. data/lib/rigor/inference/flow_tracer.rb +180 -0
  45. data/lib/rigor/inference/macro_block_self_type.rb +11 -12
  46. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  47. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  48. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  49. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  50. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  51. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  52. data/lib/rigor/inference/method_dispatcher.rb +187 -55
  53. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  54. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  55. data/lib/rigor/inference/mutation_widening.rb +142 -0
  56. data/lib/rigor/inference/narrowing.rb +330 -37
  57. data/lib/rigor/inference/scope_indexer.rb +770 -39
  58. data/lib/rigor/inference/statement_evaluator.rb +998 -68
  59. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  60. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  61. data/lib/rigor/plugin/base.rb +517 -120
  62. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  63. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  64. data/lib/rigor/plugin/macro.rb +2 -3
  65. data/lib/rigor/plugin/manifest.rb +4 -24
  66. data/lib/rigor/plugin/node_rule_walk.rb +192 -0
  67. data/lib/rigor/plugin/registry.rb +264 -35
  68. data/lib/rigor/plugin.rb +1 -0
  69. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  70. data/lib/rigor/scope/discovery_index.rb +60 -0
  71. data/lib/rigor/scope.rb +199 -204
  72. data/lib/rigor/sig_gen/generator.rb +8 -0
  73. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  74. data/lib/rigor/source/literals.rb +14 -0
  75. data/lib/rigor/triage/catalogue.rb +4 -19
  76. data/lib/rigor/triage.rb +69 -1
  77. data/lib/rigor/type/combinator.rb +34 -0
  78. data/lib/rigor/version.rb +1 -1
  79. data/lib/rigor.rb +0 -1
  80. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  81. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  82. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  83. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  84. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  85. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  86. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
  87. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  88. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
  89. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  90. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  91. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  93. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  94. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  96. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  97. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  98. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  99. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  100. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  102. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  103. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  104. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  105. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  106. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  107. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
  108. data/sig/rigor/analysis/fact_store.rbs +3 -0
  109. data/sig/rigor/environment.rbs +0 -2
  110. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  111. data/sig/rigor/inference.rbs +5 -0
  112. data/sig/rigor/plugin/base.rbs +6 -4
  113. data/sig/rigor/plugin/manifest.rbs +1 -2
  114. data/sig/rigor/scope.rbs +50 -29
  115. data/sig/rigor/source.rbs +1 -0
  116. data/sig/rigor/type.rbs +1 -0
  117. data/sig/rigor.rbs +1 -1
  118. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  119. data/skills/rigor-ci-setup/SKILL.md +319 -0
  120. data/skills/rigor-plugin-author/SKILL.md +6 -4
  121. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  122. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  123. metadata +21 -3
  124. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
  125. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -96,7 +96,8 @@ module Rigor
96
96
  parse_result = Prism.parse(source, filepath: path, version: @configuration.target_ruby)
97
97
  return if parse_result.errors.any?
98
98
 
99
- base_scope = Scope.empty(environment: environment).with_discovered_classes(discovered_classes)
99
+ base_scope = Scope.empty(environment: environment)
100
+ base_scope = base_scope.with_discovery(base_scope.discovery.with(discovered_classes: discovered_classes))
100
101
  scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
101
102
  bindings = collect_rspec_bindings(parse_result.value, scope_index)
102
103
 
@@ -104,11 +105,10 @@ module Rigor
104
105
  end
105
106
 
106
107
  # Pre-walks `@source_paths` to collect every qualified
107
- # class / module declaration. The result feeds
108
- # `Scope#with_discovered_classes` for each observe-tree
109
- # scope so `Foo.new` and `Foo` resolve to the right
110
- # singleton carrier even when no RBS sig describes
111
- # `Foo` yet.
108
+ # class / module declaration. The result seeds the
109
+ # `discovered_classes` table on each observe-tree scope so
110
+ # `Foo.new` and `Foo` resolve to the right singleton carrier
111
+ # even when no RBS sig describes `Foo` yet.
112
112
  def preindex_source_classes
113
113
  accumulator = {}
114
114
  resolve_paths(@source_paths).each { |path| harvest_classes_from(path, accumulator) }
@@ -100,6 +100,20 @@ module Rigor
100
100
  args.filter_map { |arg| symbol_or_string(arg) }
101
101
  end
102
102
 
103
+ # Whether a node is a literal `Prism::SymbolNode` that names `name`.
104
+ # The key-comparison counterpart to {.symbol_name} — for callers
105
+ # that need a predicate rather than an extraction (hash-key matching
106
+ # in keyword or assoc argument positions, e.g.
107
+ # `el.is_a?(AssocNode) && symbol_named?(el.key, "required")`).
108
+ # Uses `#unescaped` (not `#value`) for round-trip consistency.
109
+ #
110
+ # @param node [Prism::Node, nil]
111
+ # @param name [String]
112
+ # @return [Boolean]
113
+ def symbol_named?(node, name)
114
+ node.is_a?(Prism::SymbolNode) && node.unescaped == name
115
+ end
116
+
103
117
  # The literal Symbol/String at positional `index`, or `nil` when the
104
118
  # call has no argument list, the index is out of range, or the
105
119
  # argument there is not a literal Symbol/String.
@@ -304,26 +304,11 @@ module Rigor
304
304
  m && [m[1], m[2]]
305
305
  end
306
306
 
307
- # Normalises a message receiver token to a class name.
308
- # Integer / string / symbol literals fold to their class;
309
- # `Foo[...]` keeps the `Array[...]` form (H4 needs it);
310
- # `singleton(Foo)` and bare `Foo` fold to `Foo`.
307
+ # Normalises a message receiver token to a class name. The fold
308
+ # logic is shared with the selector axis see
309
+ # {Triage.normalize_receiver}.
311
310
  def receiver_class(token)
312
- t = token.strip
313
- return "Integer" if t.match?(/\A-?\d+\z/)
314
- return "Float" if t.match?(/\A-?\d+\.\d+\z/)
315
- return "String" if t.start_with?('"', "'")
316
- return "Symbol" if t.start_with?(":")
317
-
318
- singleton = t[/\Asingleton\(([\w:]+)\)\z/, 1]
319
- return singleton if singleton
320
- return t if t.start_with?("Array[")
321
-
322
- nominal = t[/\A([\w:]+)\[/, 1]
323
- return nominal if nominal
324
- return t if t.match?(/\A[\w:]+\z/)
325
-
326
- nil
311
+ Triage.normalize_receiver(token)
327
312
  end
328
313
 
329
314
  def activesupport?(receiver, method)
data/lib/rigor/triage.rb CHANGED
@@ -18,7 +18,15 @@ module Rigor
18
18
  Summary = Data.define(:total, :error, :warning, :info)
19
19
  RuleCount = Data.define(:rule, :count)
20
20
  Hotspot = Data.define(:file, :count, :by_rule)
21
- Report = Data.define(:summary, :distribution, :hotspots, :hints)
21
+ # A (receiver-class, method) dispatch target the diagnostics
22
+ # cluster on, built from the structured `Diagnostic#receiver_type`
23
+ # / `#method_name` fields — never from message-string parsing.
24
+ # `receiver` is nil for method-only diagnostics (a toplevel call,
25
+ # a `def`-side return / override finding that has no call
26
+ # receiver); `files` is the distinct-file count (a systemic vs.
27
+ # localised signal); `rules` is the per-rule breakdown.
28
+ Selector = Data.define(:receiver, :method_name, :count, :files, :rules)
29
+ Report = Data.define(:summary, :distribution, :selectors, :hotspots, :hints)
22
30
 
23
31
  module_function
24
32
 
@@ -30,6 +38,7 @@ module Rigor
30
38
  Report.new(
31
39
  summary: build_summary(diagnostics),
32
40
  distribution: build_distribution(diagnostics),
41
+ selectors: build_selectors(diagnostics),
33
42
  hotspots: build_hotspots(diagnostics, top),
34
43
  hints: hints ? Catalogue.recognise(diagnostics) : []
35
44
  )
@@ -57,6 +66,61 @@ module Rigor
57
66
  .sort_by { |row| [-row.count, row.rule] }
58
67
  end
59
68
 
69
+ # The class/method aggregation axis (ADR-23 follow-up). Groups
70
+ # every diagnostic that carries a `method_name` by its
71
+ # `(receiver_type, method_name)` pair so a consumer can answer
72
+ # "which method / class concentrates the diagnostics?" with a
73
+ # `jq` query over the JSON instead of parsing message text.
74
+ # Method-only diagnostics (nil `receiver_type`) keep a null
75
+ # `receiver` and still group by method. The full list is
76
+ # returned uncapped — the JSON is the agent-facing surface; the
77
+ # text renderer caps its own rows.
78
+ def build_selectors(diagnostics)
79
+ diagnostics.select(&:method_name)
80
+ .group_by { |d| [normalize_receiver(d.receiver_type) || d.receiver_type, d.method_name.to_s] }
81
+ .map { |(receiver, method), group| selector_for(receiver, method, group) }
82
+ .sort_by { |s| [-s.count, s.receiver.to_s, s.method_name] }
83
+ end
84
+
85
+ # Folds a receiver token — a `Diagnostic#receiver_type` display
86
+ # string or a message-parsed token — to the class the diagnostics
87
+ # should bucket under, so the selector axis does not fragment one
88
+ # method across every distinct literal receiver. String / integer
89
+ # / float / symbol literals collapse to their class; `singleton(C)`
90
+ # and a bare `C` fold to `C`; a generic `C[...]` keeps the
91
+ # `Array[String]` element form (the AR-relation heuristic keys on
92
+ # it). Returns nil for a token it cannot reduce to a class (a
93
+ # union display, an inferred shape) — the caller keeps the raw
94
+ # string then, never losing the row. Shared with {Catalogue}.
95
+ def normalize_receiver(token)
96
+ return nil if token.nil?
97
+
98
+ t = token.to_s.strip
99
+ return "Integer" if t.match?(/\A-?\d+\z/)
100
+ return "Float" if t.match?(/\A-?\d+\.\d+\z/)
101
+ return "String" if t.start_with?('"', "'")
102
+ return "Symbol" if t.start_with?(":")
103
+
104
+ singleton = t[/\Asingleton\(([\w:]+)\)\z/, 1]
105
+ return singleton if singleton
106
+ return t if t.start_with?("Array[")
107
+
108
+ nominal = t[/\A([\w:]+)\[/, 1]
109
+ return nominal if nominal
110
+ return t if t.match?(/\A[\w:]+\z/)
111
+
112
+ nil
113
+ end
114
+
115
+ def selector_for(receiver, method, group)
116
+ rules = group.group_by { |d| rule_key(d) }
117
+ .transform_values(&:size)
118
+ .sort_by { |rule, count| [-count, rule] }
119
+ .to_h
120
+ Selector.new(receiver: receiver, method_name: method, count: group.size,
121
+ files: group.map(&:path).uniq.size, rules: rules)
122
+ end
123
+
60
124
  def build_hotspots(diagnostics, top)
61
125
  diagnostics.group_by(&:path)
62
126
  .map { |path, group| hotspot_for(path, group) }
@@ -79,6 +143,10 @@ module Rigor
79
143
  "warning" => report.summary.warning, "info" => report.summary.info
80
144
  },
81
145
  "distribution" => report.distribution.map { |r| { "rule" => r.rule, "count" => r.count } },
146
+ "selectors" => report.selectors.map do |s|
147
+ { "receiver" => s.receiver, "method" => s.method_name, "count" => s.count,
148
+ "files" => s.files, "rules" => s.rules }
149
+ end,
82
150
  "hotspots" => report.hotspots.map do |h|
83
151
  { "file" => h.file, "count" => h.count, "by_rule" => h.by_rule }
84
152
  end,
@@ -17,6 +17,7 @@ require_relative "refined"
17
17
  require_relative "intersection"
18
18
  require_relative "bound_method"
19
19
  require_relative "../inference/budget_trace"
20
+ require_relative "../inference/flow_tracer"
20
21
 
21
22
  module Rigor
22
23
  module Type
@@ -79,6 +80,35 @@ module Rigor
79
80
  Constant.new(value)
80
81
  end
81
82
 
83
+ # Widens every value-pinned (`Constant`) constituent of `type` to its
84
+ # nominal base (`Constant[1]` -> `Nominal["Integer"]`), recursing
85
+ # through unions and leaving non-pinned constituents untouched.
86
+ # `Constant[nil]` is preserved (no nominal base of interest). Shared
87
+ # by the ADR-55 recursive-return fixpoint and the ADR-56 block /
88
+ # loop-body fixpoint (`BodyFixpoint`) to force convergence on the
89
+ # final permitted iteration.
90
+ def widen_value_pinned(type)
91
+ case type
92
+ when Constant
93
+ type.value.nil? ? type : nominal_of(type.value.class.name)
94
+ when Refined
95
+ # A refinement (`non-empty-string`) is a value-narrowed nominal;
96
+ # widen it to its base so an accumulator whose per-pass result is
97
+ # a bounded refinement converges on the final iteration rather
98
+ # than flooring to `Dynamic[top]` (ADR-56).
99
+ widen_value_pinned(type.base)
100
+ when IntegerRange
101
+ # `int<1, 6>` is likewise a value-narrowed `Integer` (it erases to
102
+ # `Integer` in RBS); widen it so a bounded-int accumulator
103
+ # converges (ADR-56).
104
+ nominal_of("Integer")
105
+ when Union
106
+ union(*type.members.map { |member| widen_value_pinned(member) })
107
+ else
108
+ type
109
+ end
110
+ end
111
+
82
112
  # `Object#method(:name)` carrier. Stores the bound
83
113
  # `(receiver, method_name)` pair so the dispatcher can
84
114
  # substitute the original dispatch at `.call` / `.()` /
@@ -382,6 +412,10 @@ module Rigor
382
412
  if Inference::BudgetTrace.enabled? && result.is_a?(Union)
383
413
  Inference::BudgetTrace.observe(Inference::BudgetTrace::UNION_ARITY, result.members.size)
384
414
  end
415
+ # `rigor trace` — degenerate merges (`1 | 1 → 1`, Dynamic
416
+ # absorption) are recorded too; the collapse itself is the
417
+ # teachable moment.
418
+ Inference::FlowTracer.union(types, result) if Inference::FlowTracer.active? && types.size >= 2
385
419
  result
386
420
  end
387
421
 
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.17"
4
+ VERSION = "0.1.19"
5
5
  end
data/lib/rigor.rb CHANGED
@@ -30,7 +30,6 @@ require_relative "rigor/cache/rbs_known_class_names"
30
30
  require_relative "rigor/cache/rbs_class_ancestor_table"
31
31
  require_relative "rigor/cache/rbs_class_type_param_names"
32
32
  require_relative "rigor/cache/rbs_environment"
33
- require_relative "rigor/cache/rbs_instance_definitions"
34
33
  require_relative "rigor/type_node"
35
34
  require_relative "rigor/flow_contribution"
36
35
  require_relative "rigor/flow_contribution/fact"
@@ -70,7 +70,12 @@ module Rigor
70
70
  }
71
71
  )
72
72
 
73
- producer :channel_index do |_params|
73
+ # `watch:` covers every `.rb` file under the channel search paths
74
+ # so the cache invalidates when channels are added, removed, or
75
+ # edited (ADR-60 WD3). The dependency descriptor is recorded after
76
+ # the discoverer runs, so the `io_boundary` reads inside it are
77
+ # captured too.
78
+ producer :channel_index, watch: -> { [[@channel_search_paths, "**/*.rb"]] } do |_params|
74
79
  ChannelDiscoverer.new(
75
80
  io_boundary: io_boundary,
76
81
  search_paths: @channel_search_paths,
@@ -81,54 +86,33 @@ module Rigor
81
86
  def init(_services)
82
87
  @channel_search_paths = Array(config.fetch("channel_search_paths")).map(&:to_s)
83
88
  @channel_base_classes = Array(config.fetch("channel_base_classes")).map(&:to_s)
84
- @channel_index = nil
85
- @load_error = nil
86
89
  end
87
90
 
88
91
  # File-level only: the load-error emission. Per-call broadcast
89
92
  # validation runs over the engine-owned walk via the node_rule
90
93
  # below (ADR-37). The channel index is lazily loaded + memoised by
91
- # channel_index_or_nil, shared by both surfaces.
94
+ # `producer_value`, shared by both surfaces.
92
95
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
93
- index = channel_index_or_nil
94
- return [load_error_diagnostic(path)] if index.nil? && @load_error
96
+ index = producer_value(:channel_index)
97
+ return [load_error_diagnostic(path)] if index.nil? && producer_error(:channel_index)
95
98
 
96
99
  []
97
100
  end
98
101
 
99
102
  node_rule Prism::CallNode do |node, _scope, path|
100
- index = channel_index_or_nil
103
+ index = producer_value(:channel_index)
101
104
  next [] if index.nil? || index.empty?
102
105
 
103
- Analyzer.violations_for(call_node: node, channel_index: index).map do |violation|
104
- diagnostic(node, path: path, message: violation.message, severity: violation.severity, rule: violation.rule)
105
- end
106
+ diagnostics_for(Analyzer.violations_for(call_node: node, channel_index: index), path: path, node: node)
106
107
  end
107
108
 
108
109
  private
109
110
 
110
- def channel_index_or_nil
111
- return @channel_index if @channel_index
112
-
113
- # Pass an explicit descriptor covering every `.rb` file
114
- # under the configured channel search paths so the cache
115
- # invalidates when channels are added, removed, or edited.
116
- # Without it the auto-built descriptor depends on the
117
- # `IoBoundary`'s in-process read history — empty on the
118
- # first call of a fresh process, so warm cache hits would
119
- # serve stale `ChannelIndex` data when project files have
120
- # changed between sessions.
121
- descriptor = glob_descriptor(@channel_search_paths, "**/*.rb")
122
- @channel_index = cache_for(:channel_index, params: {}, descriptor: descriptor).call
123
- rescue StandardError => e
124
- @load_error = "rigor-actioncable: failed to discover channels: #{e.class}: #{e.message}"
125
- nil
126
- end
127
-
128
111
  def load_error_diagnostic(path)
112
+ error = producer_error(:channel_index)
129
113
  Rigor::Analysis::Diagnostic.new(
130
114
  path: path, line: 1, column: 1,
131
- message: @load_error,
115
+ message: "rigor-actioncable: failed to discover channels: #{error.class}: #{error.message}",
132
116
  severity: :warning,
133
117
  rule: "load-error"
134
118
  )
@@ -65,7 +65,13 @@ module Rigor
65
65
  }
66
66
  )
67
67
 
68
- producer :mailer_index do |_params|
68
+ # `watch:` covers every mailer class under `mailer_search_paths`
69
+ # AND every view template under `views_root` (ADR-60 WD3) — a
70
+ # newly-added view a mailer references must invalidate the index,
71
+ # and `view_exists?` failures the producer never records would
72
+ # otherwise be invisible to the dependency descriptor.
73
+ producer :mailer_index,
74
+ watch: -> { [[@mailer_search_paths, "**/*.rb"], [@views_root, "**/*"]] } do |_params|
69
75
  MailerDiscoverer.new(
70
76
  io_boundary: io_boundary,
71
77
  search_paths: @mailer_search_paths,
@@ -78,8 +84,6 @@ module Rigor
78
84
  @mailer_search_paths = Array(config.fetch("mailer_search_paths")).map(&:to_s)
79
85
  @mailer_base_classes = Array(config.fetch("mailer_base_classes")).map(&:to_s)
80
86
  @views_root = config.fetch("views_root").to_s
81
- @mailer_index = nil
82
- @load_error = nil
83
87
  end
84
88
 
85
89
  # File-level: load-error + the missing-view check (anchored on the
@@ -88,46 +92,22 @@ module Rigor
88
92
  # arity) runs over the engine-owned walk via the node_rule below
89
93
  # (ADR-37). The mailer index is lazily loaded + memoised, shared.
90
94
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
91
- index = mailer_index_or_nil
92
- return [load_error_diagnostic(path)] if index.nil? && @load_error
95
+ index = producer_value(:mailer_index)
96
+ return [load_error_diagnostic(path)] if index.nil? && producer_error(:mailer_index)
93
97
  return [] if index.nil? || index.empty?
94
98
 
95
99
  missing_view_diagnostics(path, index)
96
100
  end
97
101
 
98
102
  node_rule Prism::CallNode do |node, _scope, path|
99
- index = mailer_index_or_nil
103
+ index = producer_value(:mailer_index)
100
104
  next [] if index.nil? || index.empty?
101
105
 
102
- Analyzer.violations_for(call_node: node, mailer_index: index).map do |violation|
103
- diagnostic(node, path: path, message: violation.message, severity: violation.severity, rule: violation.rule)
104
- end
106
+ diagnostics_for(Analyzer.violations_for(call_node: node, mailer_index: index), path: path, node: node)
105
107
  end
106
108
 
107
109
  private
108
110
 
109
- def mailer_index_or_nil
110
- return @mailer_index if @mailer_index
111
-
112
- # Two-glob descriptor: every mailer class under
113
- # `mailer_search_paths` AND every view template under
114
- # `views_root`. Without explicit enumeration the cache
115
- # invalidates only on files the `IoBoundary` has already
116
- # read in the current process — empty on the first call
117
- # of a fresh process, so warm hits would serve stale
118
- # `MailerIndex` data after mailers are added / removed or
119
- # view templates are added (`view_exists?` failures aren't
120
- # recorded, so the auto-built descriptor cannot detect a
121
- # newly-added view).
122
- mailer_d = glob_descriptor(@mailer_search_paths, "**/*.rb")
123
- view_d = glob_descriptor([@views_root], "**/*")
124
- descriptor = Rigor::Cache::Descriptor.compose(mailer_d, view_d)
125
- @mailer_index = cache_for(:mailer_index, params: {}, descriptor: descriptor).call
126
- rescue StandardError => e
127
- @load_error = "rigor-actionmailer: failed to discover mailers: #{e.class}: #{e.message}"
128
- nil
129
- end
130
-
131
111
  # Anchors `missing-view` diagnostics on the mailer file
132
112
  # itself: when the file currently being analysed is the
133
113
  # mailer's source file, emit one diagnostic per missing
@@ -158,9 +138,10 @@ module Rigor
158
138
  end
159
139
 
160
140
  def load_error_diagnostic(path)
141
+ error = producer_error(:mailer_index)
161
142
  Rigor::Analysis::Diagnostic.new(
162
143
  path: path, line: 1, column: 1,
163
- message: @load_error,
144
+ message: "rigor-actionmailer: failed to discover mailers: #{error.class}: #{error.message}",
164
145
  severity: :warning,
165
146
  rule: "load-error"
166
147
  )
@@ -478,8 +478,7 @@ module Rigor
478
478
  def partial_target_from_kwargs(hash_node, controller_path)
479
479
  partial_value = hash_node.elements.find do |elem|
480
480
  elem.is_a?(Prism::AssocNode) &&
481
- elem.key.is_a?(Prism::SymbolNode) &&
482
- elem.key.value == "partial"
481
+ Source::Literals.symbol_named?(elem.key, "partial")
483
482
  end&.value
484
483
  return nil unless partial_value.is_a?(Prism::StringNode)
485
484
 
@@ -91,27 +91,22 @@ module Rigor
91
91
  ]
92
92
  )
93
93
 
94
- # Phase 2 cached producer — the controller index built
95
- # from `controller_search_paths`. The IoBoundary records
96
- # a `FileEntry` digest for every file the discoverer
97
- # reads, so the cache invalidates when any controller
98
- # file changes.
99
- producer :controller_index do |_params|
94
+ # Phase 2 cached producer — the controller index built from
95
+ # `controller_search_paths`. `watch:` (ADR-60 WD3) covers every
96
+ # `.rb` file under those roots so the cache invalidates when a
97
+ # controller is added, removed, or edited; the discoverer's
98
+ # in-block `io_boundary` reads are captured into the dependency
99
+ # descriptor too, so no explicit priming is needed.
100
+ producer :controller_index, watch: -> { [[@controller_search_paths, "**/*.rb"]] } do |_params|
100
101
  ControllerDiscoverer.new(
101
102
  io_boundary: io_boundary,
102
103
  search_paths: @controller_search_paths
103
104
  ).discover
104
105
  end
105
106
 
106
- def init(services)
107
- @services = services
107
+ def init(_services)
108
108
  @controller_search_paths = Array(config.fetch("controller_search_paths")).map(&:to_s)
109
109
  @view_search_paths = Array(config.fetch("view_search_paths")).map(&:to_s)
110
- @helper_table = nil
111
- @helper_table_resolved = false
112
- @controller_index = nil
113
- @model_index_value = nil
114
- @model_index_resolved = false
115
110
  end
116
111
 
117
112
  # ADR-37 — the four Action Pack phases run per-call over the
@@ -123,16 +118,16 @@ module Rigor
123
118
  # The filter / render phases read the enclosing controller from the
124
119
  # node-rule `NodeContext` ancestors (its fifth block argument).
125
120
 
126
- # Phase 4 — route-helper consumption.
121
+ # Phase 4 — route-helper consumption. `:helper_table` is
122
+ # rigor-rails-routes's published fact (ADR-9), read lazily via
123
+ # `read_fact`.
127
124
  node_rule Prism::CallNode do |node, _scope, path|
128
125
  next [] unless controller_file?(path)
129
126
 
130
- table = helper_table
127
+ table = read_fact(plugin_id: "rails-routes", name: :helper_table)
131
128
  next [] if table.nil? || table.empty?
132
129
 
133
- Analyzer.helper_violations_for(call_node: node, helper_table: table).map do |v|
134
- diagnostic(node, path: path, location: v.location, message: v.message, severity: v.severity, rule: v.rule)
135
- end
130
+ diagnostics_for(Analyzer.helper_violations_for(call_node: node, helper_table: table), path: path, node: node)
136
131
  end
137
132
 
138
133
  # Phase 2 — filter-chain validation. Skips silently when the
@@ -141,12 +136,13 @@ module Rigor
141
136
  node_rule Prism::CallNode do |node, _scope, path, _fc, context|
142
137
  next [] unless controller_file?(path)
143
138
 
144
- index = controller_index_or_nil
139
+ index = producer_value(:controller_index)
145
140
  next [] if index.nil? || index.empty?
146
141
 
147
- Analyzer.filter_violations_for(call_node: node, ancestors: context.ancestors, controller_index: index).map do |v|
148
- diagnostic(node, path: path, location: v.location, message: v.message, severity: v.severity, rule: v.rule)
149
- end
142
+ diagnostics_for(
143
+ Analyzer.filter_violations_for(call_node: node, ancestors: context.ancestors, controller_index: index),
144
+ path: path, node: node
145
+ )
150
146
  end
151
147
 
152
148
  # Phase 3 — render-target validation against the configured
@@ -157,12 +153,13 @@ module Rigor
157
153
  node_rule Prism::CallNode do |node, _scope, path, _fc, context|
158
154
  next [] unless controller_file?(path)
159
155
 
160
- Analyzer.render_violations_for(
161
- call_node: node, ancestors: context.ancestors, path: path,
162
- view_search_roots: @view_search_paths, controller_index: controller_index_or_nil
163
- ).map do |v|
164
- diagnostic(node, path: path, location: v.location, message: v.message, severity: v.severity, rule: v.rule)
165
- end
156
+ diagnostics_for(
157
+ Analyzer.render_violations_for(
158
+ call_node: node, ancestors: context.ancestors, path: path,
159
+ view_search_roots: @view_search_paths, controller_index: producer_value(:controller_index)
160
+ ),
161
+ path: path, node: node
162
+ )
166
163
  end
167
164
 
168
165
  # Phase 1 — strong-parameter validation. Reads the `:model_index`
@@ -173,74 +170,14 @@ module Rigor
173
170
  node_rule Prism::CallNode do |node, _scope, path|
174
171
  next [] unless controller_file?(path)
175
172
 
176
- index = model_index
173
+ index = read_fact(plugin_id: "activerecord", name: :model_index)
177
174
  next [] if index.nil? || index.empty?
178
175
 
179
- Analyzer.permit_violations_for(call_node: node, model_index: index).map do |v|
180
- diagnostic(node, path: path, location: v.location, message: v.message, severity: v.severity, rule: v.rule)
181
- end
176
+ diagnostics_for(Analyzer.permit_violations_for(call_node: node, model_index: index), path: path, node: node)
182
177
  end
183
178
 
184
179
  private
185
180
 
186
- def controller_index_or_nil
187
- return @controller_index if @controller_index
188
-
189
- # Read project source first so the IoBoundary's
190
- # FileEntry digests get captured into the descriptor
191
- # before `cache_for` snapshots it (mirrors
192
- # rigor-rails-routes / rigor-pundit's pattern).
193
- prime_io_boundary_for_index
194
- @controller_index = cache_for(:controller_index, params: {}).call
195
- rescue StandardError
196
- nil
197
- end
198
-
199
- def prime_io_boundary_for_index
200
- @controller_search_paths.each do |root|
201
- absolute = File.expand_path(root)
202
- next unless File.directory?(absolute)
203
-
204
- Dir.glob(File.join(absolute, "**", "*.rb")).each do |path|
205
- io_boundary.read_file(path)
206
- rescue Plugin::AccessDeniedError, Errno::ENOENT
207
- nil
208
- end
209
- end
210
- end
211
-
212
- # Lazily resolves the helper table from the cross-plugin
213
- # fact store. The cache is per-run because the runner
214
- # builds a fresh `FactStore` per invocation; memoizing on
215
- # the plugin instance saves the per-file `read` while
216
- # still picking up a freshly-published table on the next
217
- # `bundle exec rigor check` run.
218
- def helper_table
219
- return @helper_table if @helper_table_resolved
220
-
221
- @helper_table = @services.fact_store.read(
222
- plugin_id: "rails-routes", name: :helper_table
223
- )
224
- @helper_table_resolved = true
225
- @helper_table
226
- end
227
-
228
- # Phase 1 — lazily reads the cross-plugin :model_index
229
- # fact from rigor-activerecord. The cache is per-run
230
- # because the runner builds a fresh FactStore per
231
- # invocation; memoizing on the plugin instance saves the
232
- # per-file read while still picking up a freshly
233
- # published index on the next `bundle exec rigor check`.
234
- def model_index
235
- return @model_index_value if @model_index_resolved
236
-
237
- @model_index_value = @services.fact_store.read(
238
- plugin_id: "activerecord", name: :model_index
239
- )
240
- @model_index_resolved = true
241
- @model_index_value
242
- end
243
-
244
181
  def controller_file?(path)
245
182
  @controller_search_paths.any? do |root|
246
183
  # The runner may pass `path` as either an absolute