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
@@ -48,12 +48,12 @@ module Rigor
48
48
  }
49
49
  )
50
50
 
51
- # Cached: discovered job index. The producer reads every
52
- # file under `job_search_paths` via the trusted
53
- # `IoBoundary`; the descriptor's auto-collected
54
- # `FileEntry` digests invalidate the cache when any of
55
- # those files change.
56
- producer :job_index do |_params|
51
+ # Cached: discovered job index. `watch:` (ADR-60 WD3) covers
52
+ # every `.rb` under `job_search_paths` so the cache invalidates
53
+ # when a job is added, removed, or edited; the discoverer's
54
+ # in-block `IoBoundary` reads are captured into the record-and-
55
+ # validate dependency descriptor after the block runs.
56
+ producer :job_index, watch: -> { [[@job_search_paths, "**/*.rb"]] } do |_params|
57
57
  JobDiscoverer.new(
58
58
  io_boundary: io_boundary,
59
59
  search_paths: @job_search_paths,
@@ -64,50 +64,33 @@ module Rigor
64
64
  def init(_services)
65
65
  @job_search_paths = Array(config.fetch("job_search_paths")).map(&:to_s)
66
66
  @job_base_classes = Array(config.fetch("job_base_classes")).map(&:to_s)
67
- @job_index = nil
68
- @load_error = nil
69
67
  end
70
68
 
71
69
  # File-level only: the load-error emission. Per-call arity
72
70
  # validation runs over the engine-owned walk via the node_rule
73
71
  # below (ADR-37). The job index is lazily loaded + memoised by
74
- # job_index_or_nil, shared by both surfaces.
72
+ # `producer_value`, shared by both surfaces.
75
73
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
76
- index = job_index_or_nil
77
- return [load_error_diagnostic(path)] if index.nil? && @load_error
74
+ index = producer_value(:job_index)
75
+ return [load_error_diagnostic(path)] if index.nil? && producer_error(:job_index)
78
76
 
79
77
  []
80
78
  end
81
79
 
82
80
  node_rule Prism::CallNode do |node, _scope, path|
83
- index = job_index_or_nil
81
+ index = producer_value(:job_index)
84
82
  next [] if index.nil? || index.empty?
85
83
 
86
- Analyzer.violations_for(call_node: node, job_index: index).map do |violation|
87
- diagnostic(node, path: path, message: violation.message, severity: violation.severity, rule: violation.rule)
88
- end
84
+ diagnostics_for(Analyzer.violations_for(call_node: node, job_index: index), path: path, node: node)
89
85
  end
90
86
 
91
87
  private
92
88
 
93
- def job_index_or_nil
94
- return @job_index if @job_index
95
-
96
- # Read-then-cache pattern: the discoverer's
97
- # IoBoundary reads happen INSIDE `discover`, which is
98
- # invoked through `cache_for`'s producer block. The
99
- # boundary's accumulated FileEntry digests get
100
- # captured into the descriptor at cache_for time.
101
- @job_index = cache_for(:job_index, params: {}).call
102
- rescue StandardError => e
103
- @load_error = "rigor-activejob: failed to discover jobs: #{e.class}: #{e.message}"
104
- nil
105
- end
106
-
107
89
  def load_error_diagnostic(path)
90
+ error = producer_error(:job_index)
108
91
  Rigor::Analysis::Diagnostic.new(
109
92
  path: path, line: 1, column: 1,
110
- message: @load_error,
93
+ message: "rigor-activejob: failed to discover jobs: #{error.class}: #{error.message}",
111
94
  severity: :warning,
112
95
  rule: "load-error"
113
96
  )
@@ -365,8 +365,7 @@ module Rigor
365
365
  next unless arg.is_a?(Prism::KeywordHashNode)
366
366
 
367
367
  arg.elements.each do |pair|
368
- next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
369
- next unless pair.key.unescaped == key
368
+ next unless pair.is_a?(Prism::AssocNode) && Source::Literals.symbol_named?(pair.key, key)
370
369
 
371
370
  return true if pair.value.is_a?(Prism::TrueNode)
372
371
  return false if pair.value.is_a?(Prism::FalseNode)
@@ -380,8 +379,7 @@ module Rigor
380
379
  next unless arg.is_a?(Prism::KeywordHashNode)
381
380
 
382
381
  arg.elements.each do |pair|
383
- next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
384
- next unless pair.key.unescaped == "class_name"
382
+ next unless pair.is_a?(Prism::AssocNode) && Source::Literals.symbol_named?(pair.key, "class_name")
385
383
  next unless pair.value.is_a?(Prism::StringNode)
386
384
 
387
385
  return pair.value.unescaped
@@ -27,10 +27,12 @@ module Rigor
27
27
  # whose direct superclass is in `model_base_classes`, and
28
28
  # composes them with the schema table into a {ModelIndex}.
29
29
  #
30
- # Both producers ride `Plugin::Base#cache_for`. The descriptor
31
- # auto-includes the digests of every file the boundary read,
32
- # so editing `db/schema.rb` or any model file invalidates
33
- # exactly the right cache entry.
30
+ # Both producers ride `Plugin::Base#cache_for` (ADR-60 WD3
31
+ # record-and-validate): each producer's in-block boundary reads
32
+ # are captured into its dependency descriptor after the block
33
+ # runs, and `model_index`'s `watch:` covers model-file additions,
34
+ # so editing `db/schema.rb`, editing any model, or adding a new
35
+ # model file invalidates exactly the right cache entry.
34
36
  #
35
37
  # The per-file `#diagnostics_for_file` hook delegates to
36
38
  # {Analyzer}, which walks Prism and emits diagnostics for
@@ -101,8 +103,11 @@ module Rigor
101
103
  end
102
104
 
103
105
  # Cached: model index. Walks every model file, then composes
104
- # the rows with the cached schema table.
105
- producer :model_index do |_params|
106
+ # the rows with the cached schema table. `watch:` (ADR-60 WD3)
107
+ # covers model-file additions; the discoverer's in-block reads
108
+ # are captured into the record-and-validate dependency
109
+ # descriptor after the block runs.
110
+ producer :model_index, watch: -> { [[@model_search_paths, "**/*.rb"]] } do |_params|
106
111
  rows = ModelDiscoverer.new(
107
112
  io_boundary: io_boundary,
108
113
  search_paths: @model_search_paths,
@@ -197,45 +202,83 @@ module Rigor
197
202
  MIGRATION_PATH_PATTERNS.any? { |pattern| path_s.match?(pattern) }
198
203
  end
199
204
 
200
- # v0.1.2 return-type contribution. `Model.find(id)`
201
- # narrows the call site's return type to `Nominal[Model]`,
202
- # so chained calls (`User.find(1).name`) resolve through
203
- # the analyzer's normal dispatch instead of the RBS-level
204
- # untyped fall-back. `Model.find_by(...)` narrows to
205
- # `Nominal[Model] | nil` because Rails returns nil when no
206
- # row matches. `where` / `find_or_*` are intentionally
207
- # deferredthey return relations, and Rigor does not yet
208
- # carry an Enumerable-backed relation shape that would be
209
- # more precise than the RBS envelope.
210
- def flow_contribution_for(call_node:, scope:)
205
+ # The class-side finder / relation entry-point names
206
+ # `finder_return_type` recognises. Static half of the
207
+ # `dynamic_return` name gate; the run-time half comes from
208
+ # the model index (scopes, associations, columns).
209
+ FINDER_METHOD_NAMES = %i[find find_by! find_by where all order limit none select].freeze
210
+ private_constant :FINDER_METHOD_NAMES
211
+
212
+ # v0.1.2 — return-type contribution; ADR-52 slice 5b
213
+ # migrated off `flow_contribution_for` onto the run-time
214
+ # `methods:` name gate. `Model.find(id)` narrows the call
215
+ # site's return type to `Nominal[Model]`, so chained calls
216
+ # (`User.find(1).name`) resolve through the analyzer's
217
+ # normal dispatch instead of the RBS-level untyped
218
+ # fall-back; scopes, association accessors, and column
219
+ # readers narrow per the paths below.
220
+ #
221
+ # WHY a method-name gate and not `receivers:` — the ADR-52
222
+ # "rigor-activerecord blocker": a project model not in RBS
223
+ # types its constant as `Dynamic[top]`, so a receiver-type
224
+ # gate declines exactly the calls this plugin exists for. A
225
+ # *name* gate never reads the receiver type; the block keeps
226
+ # the plugin's own AST-constant / `self_type` / `type_of`
227
+ # resolution, so the Dynamic-constant case still reaches it
228
+ # (the same shape as rigor-sorbet's catalog path). The set —
229
+ # the static finder names ∪ every scope, association, and
230
+ # column name (plus `column?` predicate forms) the model
231
+ # index discovered — is exactly the union of names the four
232
+ # resolution paths below can return a type for, so gating on
233
+ # it is byte-identical to the old ungated hook. It is broad
234
+ # (`name`, `id`, …), but membership is one Set probe and the
235
+ # expensive block runs only on candidate hits.
236
+ dynamic_return methods: -> { recognised_method_names } do |call_node, scope|
237
+ contribution_return_type(call_node, scope)
238
+ end
239
+
240
+ private
241
+
242
+ # The run-time name gate: finders ∪ scopes ∪ associations ∪
243
+ # column readers (+ `?` predicates). Resolved lazily on first
244
+ # dispatch (after `#prepare` built the index), memoised by the
245
+ # engine. Returns [] when discovery found nothing — the gate
246
+ # then declines every call, matching the old hook's
247
+ # `index.nil? || index.empty?` early return.
248
+ def recognised_method_names
249
+ index = model_index
250
+ return [] if index.nil? || index.empty?
251
+
252
+ names = FINDER_METHOD_NAMES.dup
253
+ index.entries.each_value do |entry|
254
+ names.concat(entry.scopes.map(&:to_sym))
255
+ names.concat(entry.association_names.map(&:to_sym))
256
+ entry.column_names.each do |column|
257
+ names << column.to_sym
258
+ names << :"#{column}?"
259
+ end
260
+ end
261
+ names
262
+ end
263
+
264
+ # The migrated body of the legacy `flow_contribution_for` —
265
+ # same resolution order, returning the bare type the
266
+ # `dynamic_return` contract expects.
267
+ def contribution_return_type(call_node, scope)
211
268
  return nil unless call_node.is_a?(Prism::CallNode)
212
269
 
213
270
  index = model_index
214
271
  return nil if index.nil? || index.empty?
215
272
 
216
- return_type =
217
- if call_node.receiver
218
- class_call_return_type(call_node, index) ||
219
- relation_call_return_type(call_node, scope, index) ||
220
- instance_call_return_type(call_node, scope, index)
221
- else
222
- implicit_self_class_call_return_type(call_node, scope, index)
223
- end
224
- return nil if return_type.nil?
225
-
226
- Rigor::FlowContribution.new(
227
- return_type: return_type,
228
- provenance: Rigor::FlowContribution::Provenance.new(
229
- source_family: "plugin.#{manifest.id}",
230
- plugin_id: manifest.id,
231
- node: call_node,
232
- descriptor: nil
233
- )
234
- )
273
+ if call_node.receiver
274
+ class_call_return_type(call_node, index) ||
275
+ relation_call_return_type(call_node, scope, index) ||
276
+ instance_call_return_type(call_node, scope, index)
277
+ else
278
+ implicit_self_class_call_return_type(call_node, scope, index)
279
+ end
235
280
  end
236
281
 
237
- private
238
-
239
282
  def class_call_return_type(call_node, index)
240
283
  model_name = constant_receiver_name(call_node.receiver)
241
284
  return nil if model_name.nil?
@@ -533,16 +576,11 @@ module Rigor
533
576
  table = schema_table_or_nil
534
577
  return nil if table.nil?
535
578
 
536
- # Walk model files first so the IoBoundary's digest list
537
- # captures them BEFORE `cache_for` snapshots the
538
- # descriptor (the same "read first, cache_for second"
539
- # pattern documented at the top of rigor-routes).
540
- ModelDiscoverer.new(
541
- io_boundary: io_boundary,
542
- search_paths: @model_search_paths,
543
- base_classes: @model_base_classes
544
- ).discover
545
-
579
+ # ADR-60 WD3 record-and-validate: the producer's own in-block
580
+ # `ModelDiscoverer` reads are captured into the dependency
581
+ # descriptor after the block runs, and the producer's `watch:`
582
+ # covers model-file additions so no priming walk is needed
583
+ # (it used to run the discover twice).
546
584
  @model_index = cache_for(:model_index, params: {}).call
547
585
  rescue StandardError => e
548
586
  @load_errors << "model index build failed: #{e.class}: #{e.message}"
@@ -561,9 +599,10 @@ module Rigor
561
599
  return nil if @schema_load_attempted
562
600
 
563
601
  @schema_load_attempted = true
564
- # Same pattern: read schema file via boundary, then call
565
- # cache_for so the descriptor includes the file digest.
566
- io_boundary.read_file(@schema_file)
602
+ # ADR-60 WD3 record-and-validate: the producer reads
603
+ # `@schema_file` in-block, and that read is captured into the
604
+ # dependency descriptor after the block runs — so no priming
605
+ # read is needed here.
567
606
  @schema_table = cache_for(:schema_table, params: {}).call
568
607
  rescue Plugin::AccessDeniedError => e
569
608
  @load_errors << "rigor-activerecord: #{e.message}"
@@ -12,7 +12,7 @@ module Rigor
12
12
  # attachment mapping the plugin sees.
13
13
  #
14
14
  # No `:error` diagnostics in this slice — the
15
- # `flow_contribution_for` return-type narrowing carries
15
+ # `dynamic_return` return-type narrowing carries
16
16
  # the type-checking value; surfacing unknown attachment
17
17
  # names as errors requires a coupled receiver-class
18
18
  # narrowing pass that the integration spec doesn't yet
@@ -50,8 +50,8 @@ module Rigor
50
50
  return if attachments.nil?
51
51
 
52
52
  # Only flag when the method matches a known
53
- # attachment name (the `flow_contribution_for`
54
- # tier provides the narrowing; the diagnostic just
53
+ # attachment name (the `dynamic_return` rule
54
+ # provides the narrowing; the diagnostic just
55
55
  # confirms the recognition).
56
56
  attachment = attachments.find { |a| a[:name] == node.name.to_s }
57
57
  return if attachment.nil?
@@ -54,8 +54,11 @@ module Rigor
54
54
  )
55
55
 
56
56
  # Cached: attachment index. Walks every `.rb` file under
57
- # `model_search_paths` for `has_*_attached` macros.
58
- producer :attachment_index do |_params|
57
+ # `model_search_paths` for `has_*_attached` macros. `watch:`
58
+ # (ADR-60 WD3) covers model-file additions; the discoverer's
59
+ # in-block reads are captured into the record-and-validate
60
+ # dependency descriptor after the block runs.
61
+ producer :attachment_index, watch: -> { [[@model_search_paths, "**/*.rb"]] } do |_params|
59
62
  rows = AttachmentDiscoverer.new(
60
63
  io_boundary: io_boundary,
61
64
  search_paths: @model_search_paths
@@ -79,47 +82,41 @@ module Rigor
79
82
 
80
83
  # Return-type contribution: when the receiver is
81
84
  # `Nominal[Model]` and the method matches a discovered
82
- # attachment, narrow to
85
+ # attachment, narrows to
83
86
  # `Nominal[ActiveStorage::Attached::One]` (singular) or
84
- # `Nominal[ActiveStorage::Attached::Many]` (collection).
87
+ # `Nominal[ActiveStorage::Attached::Many]` (collection)
88
+ # via a `dynamic_return` rule keyed on the live set of
89
+ # model class names from the attachment index.
85
90
  # The chained call (`.attached?`, `.purge`, `.url`)
86
91
  # then resolves through ActiveStorage's RBS surface.
87
92
  # Attachment setters (`user.avatar=`) decline — they
88
93
  # take side-effecting argument types that the RBS
89
94
  # surface already covers.
90
- def flow_contribution_for(call_node:, scope:)
91
- return nil unless call_node.is_a?(Prism::CallNode)
92
- return nil if call_node.receiver.nil?
93
- return nil unless call_node.arguments.nil?
95
+ dynamic_return receivers: -> { attachment_index&.class_names || [] } do |call_node, scope|
96
+ next nil unless call_node.is_a?(Prism::CallNode)
97
+ next nil if call_node.receiver.nil?
98
+ next nil unless call_node.arguments.nil?
94
99
 
95
100
  index = attachment_index
96
- return nil if index.nil? || index.empty?
101
+ next nil if index.nil? || index.empty?
97
102
 
98
103
  receiver_type = scope.type_of(call_node.receiver)
99
- return nil unless receiver_type.is_a?(Rigor::Type::Nominal)
104
+ next nil unless receiver_type.is_a?(Rigor::Type::Nominal)
100
105
 
101
106
  attachments = index.attachments_for(receiver_type.class_name) ||
102
107
  index.attachments_for("::#{receiver_type.class_name}")
103
- return nil if attachments.nil?
108
+ next nil if attachments.nil?
104
109
 
105
110
  attachment = attachments.find { |a| a[:name] == call_node.name.to_s }
106
- return nil if attachment.nil?
111
+ next nil if attachment.nil?
107
112
 
108
113
  target = case attachment[:kind]
109
114
  when :singular then "ActiveStorage::Attached::One"
110
115
  when :collection then "ActiveStorage::Attached::Many"
111
116
  end
112
- return nil if target.nil?
113
-
114
- Rigor::FlowContribution.new(
115
- return_type: Rigor::Type::Combinator.nominal_of(target),
116
- provenance: Rigor::FlowContribution::Provenance.new(
117
- source_family: "plugin.#{manifest.id}",
118
- plugin_id: manifest.id,
119
- node: call_node,
120
- descriptor: nil
121
- )
122
- )
117
+ next nil if target.nil?
118
+
119
+ Rigor::Type::Combinator.nominal_of(target)
123
120
  end
124
121
 
125
122
  # @!visibility private
@@ -132,12 +129,11 @@ module Rigor
132
129
  def attachment_index
133
130
  return @attachment_index if @attachment_index
134
131
 
135
- # Walk first so the IoBoundary's digest list captures
136
- # the model file digests before cache_for snapshots.
137
- AttachmentDiscoverer.new(
138
- io_boundary: io_boundary,
139
- search_paths: @model_search_paths
140
- ).discover
132
+ # ADR-60 WD3 record-and-validate: the producer's in-block
133
+ # `AttachmentDiscoverer` reads are captured into the
134
+ # dependency descriptor after the block runs, and the
135
+ # producer's `watch:` covers model-file additions — so no
136
+ # priming walk is needed (it used to run the discover twice).
141
137
  @attachment_index = cache_for(:attachment_index, params: {}).call
142
138
  rescue Plugin::AccessDeniedError => e
143
139
  @load_errors << "rigor-activestorage: #{e.message}"
@@ -5,7 +5,7 @@ require "rigor/plugin"
5
5
  module Rigor
6
6
  module Plugin
7
7
  # ADR-25 — a pure RBS-bundle plugin. It ships NO analyzer code:
8
- # no `diagnostics_for_file`, no `flow_contribution_for`. Its
8
+ # no `diagnostics_for_file`, no `dynamic_return`. Its
9
9
  # whole contribution is the manifest's `signature_paths: ["sig"]`,
10
10
  # which declares the bundled ActiveSupport `core_ext` RBS
11
11
  # directory. `Plugin::Loader` resolves that directory against
@@ -146,8 +146,7 @@ module Rigor
146
146
 
147
147
  class_pair = kwargs.elements.find do |elem|
148
148
  elem.is_a?(Prism::AssocNode) &&
149
- elem.key.is_a?(Prism::SymbolNode) &&
150
- elem.key.value == "class"
149
+ Source::Literals.symbol_named?(elem.key, "class")
151
150
  end
152
151
  return nil if class_pair.nil?
153
152
 
@@ -87,19 +87,15 @@ module Rigor
87
87
  ]
88
88
  )
89
89
 
90
- producer :factory_index do |_params|
90
+ producer :factory_index, watch: -> { [[@factory_search_paths, "**/*.rb"]] } do |_params|
91
91
  FactoryDiscoverer.new(
92
92
  io_boundary: io_boundary,
93
93
  search_paths: @factory_search_paths
94
94
  ).discover
95
95
  end
96
96
 
97
- def init(services)
98
- @services = services
97
+ def init(_services)
99
98
  @factory_search_paths = Array(config.fetch("factory_search_paths")).map(&:to_s)
100
- @factory_index = nil
101
- @model_index = nil
102
- @model_index_resolved = false
103
99
  end
104
100
 
105
101
  # ADR-37 — per-call factory/attribute validation over the
@@ -108,42 +104,17 @@ module Rigor
108
104
  # is positioned via `diagnostic(node, location:)`. No file-level
109
105
  # diagnostic remains, so there is no `diagnostics_for_file`.
110
106
  node_rule Prism::CallNode do |node, _scope, path|
111
- index = factory_index_or_nil
107
+ index = producer_value(:factory_index)
112
108
  next [] if index.nil? || index.empty?
113
109
 
114
- Analyzer.violations_for(
115
- call_node: node, factory_index: index, model_index: model_index_or_nil
116
- ).map do |violation|
117
- diagnostic(
118
- node, path: path, location: violation.location,
119
- message: violation.message, severity: violation.severity, rule: violation.rule
120
- )
121
- end
122
- end
123
-
124
- private
125
-
126
- # Phase 1 (c) — lazily resolves the :model_index fact
127
- # from rigor-activerecord. Returns nil when
128
- # rigor-activerecord isn't loaded or hasn't published
129
- # an index; the analyzer treats nil as "no cross-check"
130
- # and falls back to Phase 1 (a) behaviour (factory
131
- # attributes only).
132
- def model_index_or_nil
133
- return @model_index if @model_index_resolved
134
-
135
- @model_index = @services.fact_store.read(plugin_id: "activerecord", name: :model_index)
136
- @model_index_resolved = true
137
- @model_index
138
- end
139
-
140
- def factory_index_or_nil
141
- return @factory_index if @factory_index
142
-
143
- descriptor = glob_descriptor(@factory_search_paths, "**/*.rb")
144
- @factory_index = cache_for(:factory_index, params: {}, descriptor: descriptor).call
145
- rescue StandardError
146
- nil
110
+ # `:model_index` is rigor-activerecord's published fact (ADR-9);
111
+ # nil when that plugin isn't loaded, in which case the analyzer
112
+ # falls back to factory-attributes-only checking.
113
+ violations = Analyzer.violations_for(
114
+ call_node: node, factory_index: index,
115
+ model_index: read_fact(plugin_id: "activerecord", name: :model_index)
116
+ )
117
+ diagnostics_for(violations, path: path, node: node)
147
118
  end
148
119
  end
149
120
 
@@ -281,7 +281,7 @@ module Rigor
281
281
  return false unless kwargs.is_a?(Prism::KeywordHashNode)
282
282
 
283
283
  pair = kwargs.elements.find do |el|
284
- el.is_a?(Prism::AssocNode) && el.key.is_a?(Prism::SymbolNode) && el.key.unescaped == "required"
284
+ el.is_a?(Prism::AssocNode) && Source::Literals.symbol_named?(el.key, "required")
285
285
  end
286
286
  return false if pair.nil?
287
287
 
@@ -364,7 +364,7 @@ module Rigor
364
364
  return true unless kwargs.is_a?(Prism::KeywordHashNode)
365
365
 
366
366
  null_pair = kwargs.elements.find do |el|
367
- el.is_a?(Prism::AssocNode) && el.key.is_a?(Prism::SymbolNode) && el.key.unescaped == "null"
367
+ el.is_a?(Prism::AssocNode) && Source::Literals.symbol_named?(el.key, "null")
368
368
  end
369
369
  return true if null_pair.nil?
370
370
 
@@ -99,7 +99,7 @@ module Rigor
99
99
  receiver_constraint: "Mangrove::Enum",
100
100
  block_method: :variants,
101
101
  variant_method: :variant,
102
- name_arg_position: 0,
102
+ symbol_arg_position: 0,
103
103
  inner_arg_position: 1,
104
104
  inner_reader: :inner
105
105
  )
@@ -66,7 +66,7 @@ module Rigor
66
66
  }
67
67
  )
68
68
 
69
- producer :policy_index do |_params|
69
+ producer :policy_index, watch: -> { [[@policy_search_paths, "**/*.rb"]] } do |_params|
70
70
  PolicyDiscoverer.new(
71
71
  io_boundary: io_boundary,
72
72
  search_paths: @policy_search_paths,
@@ -77,46 +77,35 @@ module Rigor
77
77
  def init(_services)
78
78
  @policy_search_paths = Array(config.fetch("policy_search_paths")).map(&:to_s)
79
79
  @policy_base_classes = Array(config.fetch("policy_base_classes")).map(&:to_s)
80
- @policy_index = nil
81
- @load_error = nil
82
80
  end
83
81
 
84
82
  # File-level only: the load-error emission. The per-call policy
85
83
  # validation runs over the engine-owned walk via the node_rule
86
84
  # below (ADR-37). The index is lazily loaded + memoised by
87
- # policy_index_or_nil, so both surfaces share one load.
85
+ # `producer_value`, so both surfaces share one load.
88
86
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
89
- index = policy_index_or_nil
90
- return [load_error_diagnostic(path)] if index.nil? && @load_error
87
+ index = producer_value(:policy_index)
88
+ return [load_error_diagnostic(path)] if index.nil? && producer_error(:policy_index)
91
89
 
92
90
  []
93
91
  end
94
92
 
95
93
  node_rule Prism::CallNode do |node, scope, path|
96
- index = policy_index_or_nil
94
+ index = producer_value(:policy_index)
97
95
  next [] if index.nil? || index.empty?
98
96
 
99
- Analyzer.violations_for(call_node: node, policy_index: index, scope: scope).map do |violation|
100
- diagnostic(node, path: path, message: violation.message, severity: violation.severity, rule: violation.rule)
101
- end
97
+ diagnostics_for(
98
+ Analyzer.violations_for(call_node: node, policy_index: index, scope: scope), path: path, node: node
99
+ )
102
100
  end
103
101
 
104
102
  private
105
103
 
106
- def policy_index_or_nil
107
- return @policy_index if @policy_index
108
-
109
- descriptor = glob_descriptor(@policy_search_paths, "**/*.rb")
110
- @policy_index = cache_for(:policy_index, params: {}, descriptor: descriptor).call
111
- rescue StandardError => e
112
- @load_error = "rigor-pundit: failed to discover policies: #{e.class}: #{e.message}"
113
- nil
114
- end
115
-
116
104
  def load_error_diagnostic(path)
105
+ error = producer_error(:policy_index)
117
106
  Rigor::Analysis::Diagnostic.new(
118
107
  path: path, line: 1, column: 1,
119
- message: @load_error,
108
+ message: "rigor-pundit: failed to discover policies: #{error.class}: #{error.message}",
120
109
  severity: :warning,
121
110
  rule: "load-error"
122
111
  )