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.
- checksums.yaml +4 -4
- data/README.md +159 -222
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
- data/lib/rigor/analysis/check_rules.rb +275 -44
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
- data/lib/rigor/analysis/runner.rb +207 -1200
- data/lib/rigor/analysis/worker_session.rb +60 -11
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/incremental_snapshot.rb +10 -4
- data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +46 -13
- data/lib/rigor/cli/annotate_command.rb +100 -15
- data/lib/rigor/cli/check_command.rb +708 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/plugins_command.rb +2 -4
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli/triage_command.rb +6 -3
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli.rb +21 -612
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +66 -7
- data/lib/rigor/environment/rbs_loader.rb +78 -68
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -0
- data/lib/rigor/inference/body_fixpoint.rb +89 -0
- data/lib/rigor/inference/budget_trace.rb +29 -2
- data/lib/rigor/inference/expression_typer.rb +1080 -105
- data/lib/rigor/inference/flow_tracer.rb +180 -0
- data/lib/rigor/inference/macro_block_self_type.rb +11 -12
- data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
- data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
- data/lib/rigor/inference/method_dispatcher.rb +187 -55
- data/lib/rigor/inference/method_parameter_binder.rb +56 -2
- data/lib/rigor/inference/multi_target_binder.rb +46 -3
- data/lib/rigor/inference/mutation_widening.rb +142 -0
- data/lib/rigor/inference/narrowing.rb +330 -37
- data/lib/rigor/inference/scope_indexer.rb +770 -39
- data/lib/rigor/inference/statement_evaluator.rb +998 -68
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +517 -120
- data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
- data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
- data/lib/rigor/plugin/macro.rb +2 -3
- data/lib/rigor/plugin/manifest.rb +4 -24
- data/lib/rigor/plugin/node_rule_walk.rb +192 -0
- data/lib/rigor/plugin/registry.rb +264 -35
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
- data/lib/rigor/scope/discovery_index.rb +60 -0
- data/lib/rigor/scope.rb +199 -204
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/combinator.rb +34 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +0 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/inference.rbs +5 -0
- data/sig/rigor/plugin/base.rbs +6 -4
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +50 -29
- data/sig/rigor/source.rbs +1 -0
- data/sig/rigor/type.rbs +1 -0
- data/sig/rigor.rbs +1 -1
- data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
- data/skills/rigor-ci-setup/SKILL.md +319 -0
- data/skills/rigor-plugin-author/SKILL.md +6 -4
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
- metadata +21 -3
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
- 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)
|
|
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
|
|
108
|
-
# `
|
|
109
|
-
#
|
|
110
|
-
#
|
|
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
|
-
#
|
|
309
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
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
|
-
#
|
|
94
|
+
# `producer_value`, shared by both surfaces.
|
|
92
95
|
def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
|
|
93
|
-
index =
|
|
94
|
-
return [load_error_diagnostic(path)] if index.nil? &&
|
|
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 =
|
|
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)
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
92
|
-
return [load_error_diagnostic(path)] if index.nil? &&
|
|
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 =
|
|
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)
|
|
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:
|
|
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
|
|
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
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
|
|
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(
|
|
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)
|
|
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 =
|
|
139
|
+
index = producer_value(:controller_index)
|
|
145
140
|
next [] if index.nil? || index.empty?
|
|
146
141
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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)
|
|
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
|