rigortype 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +10 -2
- data/exe/rigor +19 -0
- data/lib/rigor/analysis/check_rules.rb +428 -6
- data/lib/rigor/analysis/diagnostic.rb +55 -3
- data/lib/rigor/analysis/rule_catalog.rb +80 -0
- data/lib/rigor/analysis/runner.rb +71 -2
- data/lib/rigor/analysis/worker_session.rb +3 -2
- data/lib/rigor/cache/descriptor.rb +6 -2
- data/lib/rigor/cli/plugin_command.rb +245 -0
- data/lib/rigor/cli/plugins_command.rb +51 -4
- data/lib/rigor/cli/plugins_renderer.rb +86 -1
- data/lib/rigor/cli.rb +143 -5
- data/lib/rigor/configuration/severity_profile.rb +9 -0
- data/lib/rigor/environment/rbs_loader.rb +259 -1
- data/lib/rigor/environment.rb +8 -2
- data/lib/rigor/inference/budget_trace.rb +137 -0
- data/lib/rigor/inference/expression_typer.rb +9 -2
- data/lib/rigor/inference/hkt_reducer.rb +2 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
- data/lib/rigor/inference/method_dispatcher.rb +57 -10
- data/lib/rigor/inference/precision_scanner.rb +60 -1
- data/lib/rigor/inference/scope_indexer.rb +184 -27
- data/lib/rigor/inference/statement_evaluator.rb +13 -8
- data/lib/rigor/inference/synthetic_method_index.rb +23 -4
- data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
- data/lib/rigor/plugin/additional_initializer.rb +108 -0
- data/lib/rigor/plugin/base.rb +321 -2
- data/lib/rigor/plugin/box.rb +64 -0
- data/lib/rigor/plugin/inflector.rb +121 -0
- data/lib/rigor/plugin/isolation.rb +191 -0
- data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
- data/lib/rigor/plugin/macro.rb +1 -0
- data/lib/rigor/plugin/manifest.rb +120 -23
- data/lib/rigor/plugin/node_context.rb +62 -0
- data/lib/rigor/plugin/registry.rb +10 -0
- data/lib/rigor/plugin.rb +3 -0
- data/lib/rigor/scope.rb +27 -1
- data/lib/rigor/sig_gen/generator.rb +2 -3
- data/lib/rigor/sig_gen/observation_collector.rb +2 -2
- data/lib/rigor/source/literals.rb +118 -0
- data/lib/rigor/source/node_walker.rb +26 -0
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/triage/catalogue.rb +71 -5
- data/lib/rigor/type/combinator.rb +6 -1
- data/lib/rigor/type/union.rb +65 -1
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
- data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
- 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 +6 -3
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
- data/sig/rigor/plugin/access_denied_error.rbs +3 -1
- data/sig/rigor/plugin/base.rbs +58 -3
- data/sig/rigor/plugin/io_boundary.rbs +3 -0
- data/sig/rigor/plugin/manifest.rbs +31 -1
- data/sig/rigor/scope.rbs +3 -0
- data/sig/rigor/source.rbs +12 -0
- data/sig/rigor.rbs +5 -0
- data/skills/rigor-plugin-author/SKILL.md +33 -9
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +65 -26
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +213 -80
- data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
- data/skills/rigor-project-init/SKILL.md +72 -7
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +233 -19
- metadata +53 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
|
@@ -107,22 +107,28 @@ module Rigor
|
|
|
107
107
|
# introduced method names. `rigor check` consults the
|
|
108
108
|
# table to suppress false positives for methods the
|
|
109
109
|
# user has defined but no RBS sig describes.
|
|
110
|
-
|
|
110
|
+
# Merged UNDER any cross-file pre-pass seed (like the def-node
|
|
111
|
+
# / include tables below) so a method `def`/`attr_reader`-
|
|
112
|
+
# declared in one file suppresses a false `undefined-method`
|
|
113
|
+
# for a call in another — `rigor check` seeds the project-wide
|
|
114
|
+
# table via `Runner#seed_project_scope`.
|
|
115
|
+
discovered_methods = deep_merge_class_methods(
|
|
116
|
+
default_scope.discovered_methods, build_discovered_methods(root)
|
|
117
|
+
)
|
|
111
118
|
seeded_scope = seeded_scope.with_discovered_methods(discovered_methods)
|
|
112
119
|
|
|
113
120
|
# v0.0.2 #5 + ADR-24 slice 2 — record per-instance-method
|
|
114
121
|
# def nodes, the class -> superclass map, and the
|
|
115
122
|
# class/module -> included-modules map, each merged under
|
|
116
123
|
# the cross-file pre-pass seed (see below).
|
|
117
|
-
seeded_scope = merge_project_method_indexes(seeded_scope, default_scope, root)
|
|
118
|
-
|
|
119
124
|
# v0.1.2 — per-class table of method visibilities
|
|
120
125
|
# (`:public` / `:private` / `:protected`). The
|
|
121
|
-
# `def.method-visibility-mismatch`
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
|
|
125
|
-
|
|
126
|
+
# `def.method-visibility-mismatch` and ADR-35
|
|
127
|
+
# `def.override-visibility-reduced` CheckRules consult the
|
|
128
|
+
# table. Seeded inside `merge_project_method_indexes` so the
|
|
129
|
+
# per-file visibilities merge OVER the cross-file project seed
|
|
130
|
+
# rather than overwriting it.
|
|
131
|
+
seeded_scope = merge_project_method_indexes(seeded_scope, default_scope, root)
|
|
126
132
|
|
|
127
133
|
table = {}.compare_by_identity
|
|
128
134
|
table.default = seeded_scope
|
|
@@ -161,11 +167,18 @@ module Rigor
|
|
|
161
167
|
includes = default_scope.discovered_includes.merge(
|
|
162
168
|
build_discovered_includes(root)
|
|
163
169
|
) { |_class, cross_file, per_file| (cross_file + per_file).uniq }
|
|
170
|
+
# ADR-35 — per-file visibilities merged OVER the cross-file
|
|
171
|
+
# seed (the current file is authoritative for its own classes;
|
|
172
|
+
# sibling-file ancestors are preserved from the project seed).
|
|
173
|
+
method_visibilities = default_scope.discovered_method_visibilities.merge(
|
|
174
|
+
build_discovered_method_visibilities(root)
|
|
175
|
+
) { |_class, cross_file, per_file| cross_file.merge(per_file) }
|
|
164
176
|
|
|
165
177
|
seeded_scope
|
|
166
178
|
.with_discovered_def_nodes(def_nodes)
|
|
167
179
|
.with_discovered_superclasses(superclasses)
|
|
168
180
|
.with_discovered_includes(includes)
|
|
181
|
+
.with_discovered_method_visibilities(method_visibilities)
|
|
169
182
|
end
|
|
170
183
|
|
|
171
184
|
# Slice 7 phase 2. Builds the class-level ivar accumulator
|
|
@@ -377,7 +390,7 @@ module Rigor
|
|
|
377
390
|
# class body has been walked, using `init_writes` as
|
|
378
391
|
# the soundness gate (an ivar written in `initialize`
|
|
379
392
|
# is initialised before any other method body runs).
|
|
380
|
-
collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes)
|
|
393
|
+
collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes, default_scope)
|
|
381
394
|
end
|
|
382
395
|
|
|
383
396
|
# Walks the method body in AST (== execution) order
|
|
@@ -388,14 +401,21 @@ module Rigor
|
|
|
388
401
|
# `init_writes` instead — used by the finalisation step
|
|
389
402
|
# to suppress nil contribution for ivars the constructor
|
|
390
403
|
# guarantees are initialised.
|
|
391
|
-
def collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes)
|
|
404
|
+
def collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes, default_scope = nil)
|
|
392
405
|
return if read_before_write.nil? || init_writes.nil?
|
|
393
406
|
|
|
394
407
|
seen_writes = Set.new
|
|
395
408
|
read_first = Set.new
|
|
396
409
|
detect_read_before_write(def_node.body, seen_writes, read_first)
|
|
397
410
|
|
|
398
|
-
|
|
411
|
+
# ADR-38 — `initialize` is the built-in initializer gate;
|
|
412
|
+
# a plugin may declare additional `def`-form initializer
|
|
413
|
+
# methods (minitest `setup`, Rails `after_initialize`, DI
|
|
414
|
+
# setters) on a constrained class. Both fold their writes
|
|
415
|
+
# into `init_writes`, suppressing the read-before-write nil
|
|
416
|
+
# contribution for sibling readers.
|
|
417
|
+
if def_node.name == :initialize ||
|
|
418
|
+
additional_initializer?(class_name, def_node.name, default_scope)
|
|
399
419
|
init_set = (init_writes[class_name] ||= Set.new)
|
|
400
420
|
seen_writes.each { |name| init_set << name }
|
|
401
421
|
return
|
|
@@ -407,6 +427,43 @@ module Rigor
|
|
|
407
427
|
read_first.each { |name| rbw_set << name }
|
|
408
428
|
end
|
|
409
429
|
|
|
430
|
+
# ADR-38 — true when a loaded plugin declares `method_name` an
|
|
431
|
+
# additional initializer for `class_name` (or an ancestor).
|
|
432
|
+
# Reads the plugin registry off the pre-pass scope's
|
|
433
|
+
# environment; the receiver-constraint match reuses
|
|
434
|
+
# `Environment#class_ordering` (the same mechanism ADR-16
|
|
435
|
+
# Tier A's `MacroBlockSelfType` uses). The whole lookup is
|
|
436
|
+
# wrapped so any resolution failure degrades to "no match" —
|
|
437
|
+
# since the gate only ever SUPPRESSES a nil contribution, a
|
|
438
|
+
# missed match is false-positive-safe (it merely leaves the
|
|
439
|
+
# existing nil widening in place).
|
|
440
|
+
def additional_initializer?(class_name, method_name, default_scope)
|
|
441
|
+
return false if class_name.nil? || default_scope.nil?
|
|
442
|
+
|
|
443
|
+
environment = default_scope.environment
|
|
444
|
+
registry = environment&.plugin_registry
|
|
445
|
+
return false if registry.nil?
|
|
446
|
+
return false if registry.respond_to?(:empty?) && registry.empty?
|
|
447
|
+
return false unless registry.respond_to?(:additional_initializers)
|
|
448
|
+
|
|
449
|
+
registry.additional_initializers.any? do |entry|
|
|
450
|
+
entry.covers_method?(method_name) &&
|
|
451
|
+
class_matches_constraint?(class_name, entry.receiver_constraint, environment)
|
|
452
|
+
end
|
|
453
|
+
rescue StandardError
|
|
454
|
+
false
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def class_matches_constraint?(class_name, constraint, environment)
|
|
458
|
+
return true if class_name == constraint
|
|
459
|
+
return false if environment.nil?
|
|
460
|
+
|
|
461
|
+
ordering = environment.class_ordering(class_name, constraint)
|
|
462
|
+
%i[equal subclass].include?(ordering)
|
|
463
|
+
rescue StandardError
|
|
464
|
+
false
|
|
465
|
+
end
|
|
466
|
+
|
|
410
467
|
IVAR_WRITE_NODES = [
|
|
411
468
|
Prism::InstanceVariableWriteNode,
|
|
412
469
|
Prism::InstanceVariableOrWriteNode,
|
|
@@ -796,7 +853,19 @@ module Rigor
|
|
|
796
853
|
accumulator.transform_values(&:freeze).freeze
|
|
797
854
|
end
|
|
798
855
|
|
|
799
|
-
#
|
|
856
|
+
# Merges two `class_name => { method => kind }` tables, unioning
|
|
857
|
+
# the per-class method maps (so a seeded cross-file table and the
|
|
858
|
+
# current file's table combine instead of clobbering).
|
|
859
|
+
def deep_merge_class_methods(base, overlay)
|
|
860
|
+
return overlay if base.nil? || base.empty?
|
|
861
|
+
return base if overlay.empty?
|
|
862
|
+
|
|
863
|
+
base.merge(overlay) do |_class_name, base_methods, overlay_methods|
|
|
864
|
+
base_methods.merge(overlay_methods)
|
|
865
|
+
end
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
|
|
800
869
|
def walk_methods(node, qualified_prefix, in_singleton_class, accumulator)
|
|
801
870
|
return unless node.is_a?(Prism::Node)
|
|
802
871
|
|
|
@@ -830,6 +899,10 @@ module Rigor
|
|
|
830
899
|
return
|
|
831
900
|
when Prism::CallNode
|
|
832
901
|
record_define_method(node, qualified_prefix, in_singleton_class, accumulator) if node.name == :define_method
|
|
902
|
+
if ATTR_MACROS.include?(node.name)
|
|
903
|
+
record_attr_methods(node, qualified_prefix, in_singleton_class,
|
|
904
|
+
accumulator)
|
|
905
|
+
end
|
|
833
906
|
end
|
|
834
907
|
|
|
835
908
|
node.compact_child_nodes.each do |child|
|
|
@@ -860,7 +933,7 @@ module Rigor
|
|
|
860
933
|
end
|
|
861
934
|
end
|
|
862
935
|
end
|
|
863
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
936
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
|
|
864
937
|
|
|
865
938
|
# v0.1.2 — when a `Const = Data.define(*sym) do ... end`
|
|
866
939
|
# / `Const = Struct.new(*sym) do ... end` constant write
|
|
@@ -1301,6 +1374,35 @@ module Rigor
|
|
|
1301
1374
|
accumulator[class_name][method_name] = in_singleton_class ? :singleton : :instance
|
|
1302
1375
|
end
|
|
1303
1376
|
|
|
1377
|
+
# The `attr_*` accessor macros that introduce methods Rigor must
|
|
1378
|
+
# treat as source-declared. Without this, a class that defines an
|
|
1379
|
+
# accessor with `attr_reader :x` AND carries RBS that omits `x`
|
|
1380
|
+
# (a common gap — the project ships an incomplete `sig/`) fires a
|
|
1381
|
+
# false `call.undefined-method` on `obj.x`, because the
|
|
1382
|
+
# undefined-method rule only suppressed `def` / `define_method` /
|
|
1383
|
+
# `alias_method`-discovered methods. `attr_reader` defines
|
|
1384
|
+
# readers, `attr_writer` writers (`x=`), `attr_accessor` both.
|
|
1385
|
+
ATTR_MACROS = %i[attr_reader attr_writer attr_accessor].freeze
|
|
1386
|
+
|
|
1387
|
+
def record_attr_methods(call_node, qualified_prefix, in_singleton_class, accumulator)
|
|
1388
|
+
return if qualified_prefix.empty?
|
|
1389
|
+
return unless call_node.receiver.nil? # only the implicit-self macro defines on the lexical class
|
|
1390
|
+
return if call_node.arguments.nil?
|
|
1391
|
+
|
|
1392
|
+
kind = in_singleton_class ? :singleton : :instance
|
|
1393
|
+
reader = call_node.name != :attr_writer
|
|
1394
|
+
writer = call_node.name != :attr_reader
|
|
1395
|
+
class_name = qualified_prefix.join("::")
|
|
1396
|
+
call_node.arguments.arguments.each do |arg|
|
|
1397
|
+
base = literal_method_name(arg)
|
|
1398
|
+
next if base.nil?
|
|
1399
|
+
|
|
1400
|
+
accumulator[class_name] ||= {}
|
|
1401
|
+
accumulator[class_name][base] = kind if reader
|
|
1402
|
+
accumulator[class_name][:"#{base}="] = kind if writer
|
|
1403
|
+
end
|
|
1404
|
+
end
|
|
1405
|
+
|
|
1304
1406
|
def literal_method_name(node)
|
|
1305
1407
|
return nil unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
|
|
1306
1408
|
|
|
@@ -1365,31 +1467,86 @@ module Rigor
|
|
|
1365
1467
|
# (`Mastodon::CLI::Accounts` calling a helper defined in
|
|
1366
1468
|
# `Mastodon::CLI::Base`).
|
|
1367
1469
|
#
|
|
1470
|
+
# The returned `def_sources` map mirrors `def_nodes` but stores
|
|
1471
|
+
# a `"path:line"` String per `(class_name, method_name)` instead
|
|
1472
|
+
# of the `Prism::DefNode`. A `Prism::Location` does not expose
|
|
1473
|
+
# its source file through public API, so the source site is
|
|
1474
|
+
# captured here, in the pre-pass loop that still holds `path`.
|
|
1475
|
+
# `CheckRules#undefined_method_diagnostic` consults the seeded
|
|
1476
|
+
# copy to name the defining file when a project monkey-patch on
|
|
1477
|
+
# a core/stdlib/gem class is called cross-file (ADR-17). First
|
|
1478
|
+
# write wins, matching `def_nodes`' own merge order.
|
|
1479
|
+
#
|
|
1368
1480
|
# @param paths [Array<String>] project file paths.
|
|
1369
1481
|
# @param buffer [Rigor::Analysis::BufferBinding, nil]
|
|
1370
|
-
# @return [Hash{Symbol => Hash}]
|
|
1482
|
+
# @return [Hash{Symbol => Hash}]
|
|
1483
|
+
# `{ def_nodes:, def_sources:, superclasses:, includes: }`
|
|
1371
1484
|
def discovered_def_index_for_paths(paths, buffer: nil)
|
|
1372
|
-
|
|
1373
|
-
superclasses = {}
|
|
1374
|
-
includes = {}
|
|
1485
|
+
acc = { def_nodes: {}, def_sources: {}, superclasses: {}, includes: {}, method_visibilities: {}, methods: {} }
|
|
1375
1486
|
paths.each do |path|
|
|
1376
1487
|
physical = buffer ? buffer.resolve(path) : path
|
|
1377
1488
|
root = Prism.parse(File.read(physical), filepath: path).value
|
|
1378
|
-
|
|
1379
|
-
(def_nodes[class_name] ||= {}).merge!(methods)
|
|
1380
|
-
end
|
|
1381
|
-
superclasses.merge!(build_discovered_superclasses(root))
|
|
1382
|
-
build_discovered_includes(root).each do |class_name, mods|
|
|
1383
|
-
includes[class_name] = ((includes[class_name] || []) + mods).uniq
|
|
1384
|
-
end
|
|
1489
|
+
accumulate_project_index(acc, path, root)
|
|
1385
1490
|
rescue StandardError
|
|
1386
1491
|
# Skip files that fail to parse or read; the per-file
|
|
1387
1492
|
# analyzer surfaces the parse error separately.
|
|
1388
1493
|
next
|
|
1389
1494
|
end
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1495
|
+
# Cross-file method suppression is for the project's OWN
|
|
1496
|
+
# accessors (attr_* / define_method / alias) — NOT for plain
|
|
1497
|
+
# `def`s. A cross-file `def` on a class is exactly the ADR-17
|
|
1498
|
+
# monkey-patch case the undefined-method rule deliberately
|
|
1499
|
+
# surfaces (fire + def-site annotation, nudging `pre_eval:`),
|
|
1500
|
+
# so dropping the `def`-declared names keeps that contract
|
|
1501
|
+
# intact while still letting `attr_reader :x` in one file
|
|
1502
|
+
# suppress a false undefined-method for `obj.x` in another.
|
|
1503
|
+
acc[:methods] = subtract_def_methods(acc[:methods], acc[:def_nodes])
|
|
1504
|
+
%i[def_nodes def_sources includes method_visibilities methods].each { |key| acc[key].each_value(&:freeze) }
|
|
1505
|
+
acc.transform_values(&:freeze)
|
|
1506
|
+
end
|
|
1507
|
+
|
|
1508
|
+
# Removes, per class, the method names that have a project `def`
|
|
1509
|
+
# node, leaving only accessor/alias/define_method-introduced
|
|
1510
|
+
# methods in the cross-file suppression table.
|
|
1511
|
+
def subtract_def_methods(methods, def_nodes)
|
|
1512
|
+
methods.each_with_object({}) do |(class_name, table), out|
|
|
1513
|
+
defs = def_nodes[class_name] || {}
|
|
1514
|
+
kept = table.reject { |method_name, _kind| defs.key?(method_name) }
|
|
1515
|
+
out[class_name] = kept unless kept.empty?
|
|
1516
|
+
end
|
|
1517
|
+
end
|
|
1518
|
+
|
|
1519
|
+
# Folds one file's class-keyed indexes into the cross-file
|
|
1520
|
+
# accumulator. `method_visibilities` (ADR-35) is collected here so
|
|
1521
|
+
# the override-visibility-reduced rule can read an ancestor's
|
|
1522
|
+
# visibility declared in a sibling file.
|
|
1523
|
+
def accumulate_project_index(acc, path, root)
|
|
1524
|
+
merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, root)
|
|
1525
|
+
acc[:superclasses].merge!(build_discovered_superclasses(root))
|
|
1526
|
+
build_discovered_includes(root).each do |class_name, mods|
|
|
1527
|
+
acc[:includes][class_name] = ((acc[:includes][class_name] || []) + mods).uniq
|
|
1528
|
+
end
|
|
1529
|
+
build_discovered_method_visibilities(root).each do |class_name, table|
|
|
1530
|
+
(acc[:method_visibilities][class_name] ||= {}).merge!(table)
|
|
1531
|
+
end
|
|
1532
|
+
build_discovered_methods(root).each do |class_name, table|
|
|
1533
|
+
(acc[:methods][class_name] ||= {}).merge!(table)
|
|
1534
|
+
end
|
|
1535
|
+
end
|
|
1536
|
+
|
|
1537
|
+
# Merges one file's `class → method → DefNode` map into the
|
|
1538
|
+
# cross-file `def_nodes` index and records each method's first-
|
|
1539
|
+
# seen `"path:line"` definition site in `def_sources` (ADR-17 —
|
|
1540
|
+
# the un-registered-project-patch signal `call.undefined-method`
|
|
1541
|
+
# and `rigor triage` key on).
|
|
1542
|
+
def merge_discovered_defs(def_nodes, def_sources, path, root)
|
|
1543
|
+
build_discovered_def_nodes(root).each do |class_name, methods|
|
|
1544
|
+
(def_nodes[class_name] ||= {}).merge!(methods)
|
|
1545
|
+
sources = (def_sources[class_name] ||= {})
|
|
1546
|
+
methods.each do |method_name, def_node|
|
|
1547
|
+
sources[method_name] ||= "#{path}:#{def_node.location&.start_line || 1}"
|
|
1548
|
+
end
|
|
1549
|
+
end
|
|
1393
1550
|
end
|
|
1394
1551
|
|
|
1395
1552
|
# Class-only variant of `record_declarations` — descends
|
|
@@ -1371,16 +1371,21 @@ module Rigor
|
|
|
1371
1371
|
end
|
|
1372
1372
|
end
|
|
1373
1373
|
|
|
1374
|
-
#
|
|
1375
|
-
# `
|
|
1376
|
-
#
|
|
1377
|
-
#
|
|
1378
|
-
#
|
|
1374
|
+
# ADR-37 slice 2 — gathers each plugin's post-return narrowing from
|
|
1375
|
+
# BOTH the narrow `type_specifier` DSL (method-gated, wrapped as a
|
|
1376
|
+
# facts-only `FlowContribution`) and the legacy
|
|
1377
|
+
# `flow_contribution_for` escape valve, swallowing per-plugin
|
|
1378
|
+
# exceptions so a buggy plugin can't abort the assertion path.
|
|
1379
1379
|
def collect_plugin_contributions(registry, call_node, current_scope)
|
|
1380
|
-
registry.plugins.
|
|
1381
|
-
|
|
1380
|
+
registry.plugins.flat_map do |plugin|
|
|
1381
|
+
contributions = []
|
|
1382
|
+
legacy = plugin.flow_contribution_for(call_node: call_node, scope: current_scope)
|
|
1383
|
+
contributions << legacy if legacy.is_a?(Rigor::FlowContribution)
|
|
1384
|
+
facts = plugin.type_specifier_facts(call_node: call_node, scope: current_scope)
|
|
1385
|
+
contributions << Rigor::FlowContribution.new(post_return_facts: facts) if facts && !facts.empty?
|
|
1386
|
+
contributions
|
|
1382
1387
|
rescue StandardError
|
|
1383
|
-
|
|
1388
|
+
[]
|
|
1384
1389
|
end
|
|
1385
1390
|
end
|
|
1386
1391
|
|
|
@@ -29,9 +29,19 @@ module Rigor
|
|
|
29
29
|
# floor — the recorded string is the input to a later slice's
|
|
30
30
|
# precision promotion via ADR-13's `Plugin::TypeNodeResolver`.
|
|
31
31
|
class SyntheticMethodIndex
|
|
32
|
-
attr_reader :entries
|
|
32
|
+
attr_reader :entries, :class_names
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
# @param entries [Array<SyntheticMethod>]
|
|
35
|
+
# @param class_names [Array<String>, Set<String>] names of
|
|
36
|
+
# classes the substrate synthesises wholesale (ADR-36
|
|
37
|
+
# nested-class emission — the variant subclasses that have
|
|
38
|
+
# no RBS/source declaration of their own). Recorded so
|
|
39
|
+
# `Environment#class_known?` can resolve them as classes
|
|
40
|
+
# (their constant reference + `.new` dispatch) even though
|
|
41
|
+
# nothing else in the type universe declares them. Tier B/C
|
|
42
|
+
# method emissions leave this empty (their receiver classes
|
|
43
|
+
# are already real).
|
|
44
|
+
def initialize(entries: [], class_names: [])
|
|
35
45
|
unless entries.is_a?(Array) && entries.all?(SyntheticMethod)
|
|
36
46
|
raise ArgumentError,
|
|
37
47
|
"SyntheticMethodIndex#entries must be an Array of SyntheticMethod, got #{entries.inspect}"
|
|
@@ -40,11 +50,20 @@ module Rigor
|
|
|
40
50
|
@entries = Ractor.make_shareable(entries.dup)
|
|
41
51
|
@by_instance = Ractor.make_shareable(bucket(entries, SyntheticMethod::INSTANCE))
|
|
42
52
|
@by_singleton = Ractor.make_shareable(bucket(entries, SyntheticMethod::SINGLETON))
|
|
53
|
+
@class_names = Ractor.make_shareable(class_names.to_a.map(&:to_s).uniq.freeze)
|
|
54
|
+
@class_name_set = Ractor.make_shareable(@class_names.to_set)
|
|
43
55
|
freeze
|
|
44
56
|
end
|
|
45
57
|
|
|
46
58
|
def empty?
|
|
47
|
-
entries.empty?
|
|
59
|
+
entries.empty? && class_names.empty?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# True when `name` is a substrate-synthesised class (an
|
|
63
|
+
# ADR-36 variant subclass). Used by `Environment#class_known?`
|
|
64
|
+
# so the constant resolves and `.new` dispatches.
|
|
65
|
+
def knows_class?(name)
|
|
66
|
+
@class_name_set.include?(name.to_s)
|
|
48
67
|
end
|
|
49
68
|
|
|
50
69
|
# Returns an Array of matching {SyntheticMethod} records in
|
|
@@ -59,7 +78,7 @@ module Rigor
|
|
|
59
78
|
end
|
|
60
79
|
|
|
61
80
|
def to_h
|
|
62
|
-
{ "entries" => entries.map(&:to_h) }
|
|
81
|
+
{ "entries" => entries.map(&:to_h), "class_names" => class_names }
|
|
63
82
|
end
|
|
64
83
|
|
|
65
84
|
EMPTY_ROW = [].freeze
|
|
@@ -4,6 +4,7 @@ require "prism"
|
|
|
4
4
|
|
|
5
5
|
require_relative "../plugin/macro/heredoc_template"
|
|
6
6
|
require_relative "../plugin/macro/trait_registry"
|
|
7
|
+
require_relative "../source/literals"
|
|
7
8
|
require_relative "synthetic_method"
|
|
8
9
|
require_relative "synthetic_method_index"
|
|
9
10
|
|
|
@@ -69,13 +70,15 @@ module Rigor
|
|
|
69
70
|
def scan(plugin_registry:, paths:, environment: nil, fact_store: nil, buffer: nil)
|
|
70
71
|
templates = collect_templates(plugin_registry)
|
|
71
72
|
registries = collect_trait_registries(plugin_registry)
|
|
72
|
-
|
|
73
|
+
nested_templates = collect_nested_class_templates(plugin_registry)
|
|
74
|
+
return SyntheticMethodIndex::EMPTY if templates.empty? && registries.empty? && nested_templates.empty?
|
|
73
75
|
|
|
74
76
|
asts = parse_paths(paths, buffer: buffer)
|
|
75
77
|
hierarchy = build_hierarchy(asts)
|
|
76
78
|
concern_index = build_concern_index(asts)
|
|
77
79
|
|
|
78
80
|
entries = []
|
|
81
|
+
class_names = []
|
|
79
82
|
asts.each do |path, ast|
|
|
80
83
|
walk_class_bodies(ast) do |class_name, call_node|
|
|
81
84
|
collect_entries(entries, templates, class_name, call_node, hierarchy, environment, path, fact_store)
|
|
@@ -85,9 +88,10 @@ module Rigor
|
|
|
85
88
|
templates, registries, hierarchy, environment, path, fact_store
|
|
86
89
|
)
|
|
87
90
|
end
|
|
91
|
+
collect_nested_class_entries(entries, class_names, nested_templates, ast, path) unless nested_templates.empty?
|
|
88
92
|
end
|
|
89
93
|
|
|
90
|
-
SyntheticMethodIndex.new(entries: entries)
|
|
94
|
+
SyntheticMethodIndex.new(entries: entries, class_names: class_names)
|
|
91
95
|
end
|
|
92
96
|
|
|
93
97
|
# Aggregates `(plugin_id, template)` pairs across every
|
|
@@ -119,6 +123,146 @@ module Rigor
|
|
|
119
123
|
end
|
|
120
124
|
end
|
|
121
125
|
|
|
126
|
+
# ADR-36 — aggregates `(plugin_id, template)` pairs across
|
|
127
|
+
# every plugin's `manifest.nested_class_templates`. Empty when
|
|
128
|
+
# no plugin contributes the nested-class emission tier.
|
|
129
|
+
def collect_nested_class_templates(plugin_registry)
|
|
130
|
+
return [] if plugin_registry.nil? || plugin_registry.empty?
|
|
131
|
+
|
|
132
|
+
plugin_registry.plugins.flat_map do |plugin|
|
|
133
|
+
# rigor:disable undefined-method
|
|
134
|
+
plugin.manifest.nested_class_templates.map do |template|
|
|
135
|
+
[plugin.manifest.id, template]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# ADR-36 nested-class emission. For each class that `extend`s a
|
|
141
|
+
# template's `receiver_constraint` and carries a
|
|
142
|
+
# `<block_method> do ... end` block, mint one synthetic
|
|
143
|
+
# subclass per `<variant_method> <Const>, <Type>` row:
|
|
144
|
+
#
|
|
145
|
+
# class Shape
|
|
146
|
+
# extend Mangrove::Enum
|
|
147
|
+
# variants do
|
|
148
|
+
# variant Circle, Float
|
|
149
|
+
# end
|
|
150
|
+
# end
|
|
151
|
+
#
|
|
152
|
+
# yields synthetic class `Shape::Circle` + instance method
|
|
153
|
+
# `Shape::Circle#inner -> Float`. The variant subclass name is
|
|
154
|
+
# recorded in `class_names` so `Environment#class_known?`
|
|
155
|
+
# resolves the constant (and `.new` dispatches through
|
|
156
|
+
# `meta_new`); `#inner`'s return type is the literal constant
|
|
157
|
+
# type argument (non-constant inner shapes degrade to
|
|
158
|
+
# `Dynamic[Top]` per the slice-A floor).
|
|
159
|
+
def collect_nested_class_entries(entries, class_names, nested_templates, ast, path)
|
|
160
|
+
return if ast.nil?
|
|
161
|
+
|
|
162
|
+
walk_classes(ast) do |class_name, class_node|
|
|
163
|
+
body = class_body_statements(class_node)
|
|
164
|
+
next if body.empty?
|
|
165
|
+
|
|
166
|
+
nested_templates.each do |(plugin_id, template)|
|
|
167
|
+
next unless body_extends?(body, template.receiver_constraint)
|
|
168
|
+
|
|
169
|
+
each_variant_call(body, template) do |variant_const, inner_node|
|
|
170
|
+
emit_variant(entries, class_names, class_name, variant_const, inner_node, template, plugin_id, path)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Walks every class declaration, yielding its fully-qualified
|
|
177
|
+
# name and the `Prism::ClassNode`. Mirrors `walk_class_bodies`'
|
|
178
|
+
# scope-stack bookkeeping but hands back the class node itself.
|
|
179
|
+
def walk_classes(node, scope_stack = [], &)
|
|
180
|
+
return unless node.respond_to?(:compact_child_nodes)
|
|
181
|
+
|
|
182
|
+
case node
|
|
183
|
+
when Prism::ClassNode
|
|
184
|
+
name = class_name_from(node, scope_stack)
|
|
185
|
+
yield name, node if name
|
|
186
|
+
new_stack = scope_stack + [node]
|
|
187
|
+
node.body&.compact_child_nodes&.each { |child| walk_classes(child, new_stack, &) }
|
|
188
|
+
when Prism::ModuleNode
|
|
189
|
+
new_stack = scope_stack + [node]
|
|
190
|
+
node.body&.compact_child_nodes&.each { |child| walk_classes(child, new_stack, &) }
|
|
191
|
+
else
|
|
192
|
+
node.compact_child_nodes.each { |child| walk_classes(child, scope_stack, &) }
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def class_body_statements(class_node)
|
|
197
|
+
body = class_node.body
|
|
198
|
+
body.respond_to?(:body) ? body.body.compact : []
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# True when the class body carries `extend <constraint>`
|
|
202
|
+
# (receiverless `extend` call with the constraint constant as
|
|
203
|
+
# its first argument).
|
|
204
|
+
def body_extends?(body, constraint)
|
|
205
|
+
body.any? do |stmt|
|
|
206
|
+
stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? && stmt.name == :extend &&
|
|
207
|
+
const_name_string(first_arg(stmt)) == constraint
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Yields `(variant_const_name, inner_type_node)` for every
|
|
212
|
+
# `<variant_method> <Const>, <Type>` call inside the template's
|
|
213
|
+
# `<block_method> do ... end` block(s).
|
|
214
|
+
def each_variant_call(body, template, &)
|
|
215
|
+
body.each do |stmt|
|
|
216
|
+
next unless variants_block_call?(stmt, template)
|
|
217
|
+
|
|
218
|
+
block_body_statements(stmt.block).each { |call| yield_variant(call, template, &) }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def variants_block_call?(stmt, template)
|
|
223
|
+
stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? &&
|
|
224
|
+
stmt.name == template.block_method && stmt.block.is_a?(Prism::BlockNode)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def yield_variant(call, template)
|
|
228
|
+
return unless call.is_a?(Prism::CallNode) && call.receiver.nil? && call.name == template.variant_method
|
|
229
|
+
|
|
230
|
+
args = call.arguments&.arguments || []
|
|
231
|
+
variant_const = const_name_string(args[template.name_arg_position])
|
|
232
|
+
return if variant_const.nil?
|
|
233
|
+
|
|
234
|
+
yield variant_const, args[template.inner_arg_position]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def block_body_statements(block_node)
|
|
238
|
+
body = block_node.body
|
|
239
|
+
body.respond_to?(:body) ? body.body.compact : []
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def emit_variant(entries, class_names, enclosing, variant_const, inner_node, template, plugin_id, path) # rubocop:disable Metrics/ParameterLists
|
|
243
|
+
variant_class = "#{enclosing}::#{variant_const}"
|
|
244
|
+
class_names << variant_class
|
|
245
|
+
inner_type = const_name_string(inner_node) || "untyped"
|
|
246
|
+
|
|
247
|
+
entries << SyntheticMethod.new(
|
|
248
|
+
class_name: variant_class,
|
|
249
|
+
method_name: template.inner_reader,
|
|
250
|
+
return_type: inner_type,
|
|
251
|
+
kind: SyntheticMethod::INSTANCE,
|
|
252
|
+
provenance: {
|
|
253
|
+
plugin_id: plugin_id,
|
|
254
|
+
tier: "nested_class",
|
|
255
|
+
enclosing: enclosing,
|
|
256
|
+
variant: variant_const,
|
|
257
|
+
source_path: path
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def first_arg(call_node)
|
|
263
|
+
call_node.arguments&.arguments&.first
|
|
264
|
+
end
|
|
265
|
+
|
|
122
266
|
def parse_paths(paths, buffer: nil)
|
|
123
267
|
paths.to_h do |path|
|
|
124
268
|
physical = buffer ? buffer.resolve(path) : path
|
|
@@ -399,9 +543,7 @@ module Rigor
|
|
|
399
543
|
end
|
|
400
544
|
|
|
401
545
|
def literal_symbol_value(node)
|
|
402
|
-
|
|
403
|
-
when Prism::SymbolNode, Prism::StringNode then node.unescaped.to_sym
|
|
404
|
-
end
|
|
546
|
+
Source::Literals.symbol_or_string(node)
|
|
405
547
|
end
|
|
406
548
|
|
|
407
549
|
def emit_trait_module_entries(entries, class_name, modules, registry, plugin_id, path, call_node, environment) # rubocop:disable Metrics/ParameterLists
|
|
@@ -584,15 +726,7 @@ module Rigor
|
|
|
584
726
|
end
|
|
585
727
|
|
|
586
728
|
def literal_symbol_arg(call_node, index)
|
|
587
|
-
|
|
588
|
-
return nil if args_node.nil?
|
|
589
|
-
|
|
590
|
-
arg = args_node.arguments[index]
|
|
591
|
-
return nil unless arg
|
|
592
|
-
|
|
593
|
-
case arg
|
|
594
|
-
when Prism::SymbolNode, Prism::StringNode then arg.unescaped.to_sym
|
|
595
|
-
end
|
|
729
|
+
Source::Literals.symbol_arg(call_node, index)
|
|
596
730
|
end
|
|
597
731
|
end
|
|
598
732
|
end
|