rigortype 0.4.1 → 0.4.2

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 (182) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/io.yml +1 -0
  4. data/data/builtins/ruby_core/proc.yml +4 -0
  5. data/data/builtins/ruby_core/random.yml +4 -0
  6. data/data/builtins/ruby_core/struct.yml +3 -0
  7. data/data/core_overlay/env.rbs +34 -0
  8. data/data/core_overlay/gc.rbs +50 -0
  9. data/data/core_overlay/hash.rbs +45 -0
  10. data/data/core_overlay/integer.rbs +31 -0
  11. data/data/core_overlay/io_buffer.rbs +27 -0
  12. data/data/core_overlay/kernel.rbs +40 -0
  13. data/data/core_overlay/match_data.rbs +28 -0
  14. data/data/core_overlay/module.rbs +51 -0
  15. data/data/core_overlay/object_space.rbs +29 -0
  16. data/data/core_overlay/proc.rbs +45 -0
  17. data/data/core_overlay/range.rbs +40 -0
  18. data/data/core_overlay/source_range.rbs +78 -0
  19. data/data/core_overlay/string.rbs +86 -0
  20. data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
  21. data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
  22. data/docs/handbook/01-getting-started.md +3 -3
  23. data/docs/handbook/06-classes.md +3 -1
  24. data/docs/handbook/11-sig-gen.md +6 -0
  25. data/docs/handbook/appendix-liskov.md +5 -0
  26. data/docs/manual/03-configuration.md +1 -1
  27. data/docs/manual/04-diagnostics.md +2 -1
  28. data/docs/manual/06-baseline.md +2 -0
  29. data/docs/manual/07-plugins.md +1 -1
  30. data/docs/manual/18-removing-dead-code.md +32 -2
  31. data/docs/manual/plugins/README.md +6 -1
  32. data/docs/manual/plugins/rigor-activerecord.md +21 -0
  33. data/docs/manual/plugins/rigor-alba.md +60 -0
  34. data/docs/manual/plugins/rigor-minitest.md +22 -4
  35. data/docs/manual/plugins/rigor-typelizer.md +71 -0
  36. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
  37. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
  38. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
  39. data/lib/rigor/analysis/check_rules/rule_ids.rb +4 -0
  40. data/lib/rigor/analysis/check_rules/source_arity.rb +21 -2
  41. data/lib/rigor/analysis/check_rules.rb +289 -57
  42. data/lib/rigor/analysis/dependency_recorder.rb +15 -0
  43. data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
  44. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
  45. data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
  46. data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
  47. data/lib/rigor/analysis/erb_template_detector.rb +2 -1
  48. data/lib/rigor/analysis/incremental.rb +29 -9
  49. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +30 -5
  50. data/lib/rigor/analysis/incremental_run_slot.rb +3 -1
  51. data/lib/rigor/analysis/incremental_session.rb +14 -1
  52. data/lib/rigor/analysis/reachability/graph.rb +123 -23
  53. data/lib/rigor/analysis/reachability/scan.rb +290 -36
  54. data/lib/rigor/analysis/rule_catalog.rb +43 -0
  55. data/lib/rigor/analysis/run_cache_key.rb +16 -1
  56. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +87 -5
  57. data/lib/rigor/analysis/runner/pool_coordinator.rb +50 -11
  58. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  59. data/lib/rigor/analysis/runner.rb +34 -3
  60. data/lib/rigor/analysis/worker_session.rb +7 -4
  61. data/lib/rigor/cache/incremental_snapshot.rb +22 -0
  62. data/lib/rigor/cli/annotate_command.rb +5 -0
  63. data/lib/rigor/cli/baseline_command.rb +7 -18
  64. data/lib/rigor/cli/check_command.rb +2 -1
  65. data/lib/rigor/cli/trace_command.rb +5 -0
  66. data/lib/rigor/cli/type_of_command.rb +5 -3
  67. data/lib/rigor/cli/unused_command.rb +13 -7
  68. data/lib/rigor/cli.rb +10 -2
  69. data/lib/rigor/configuration/severity_profile.rb +6 -0
  70. data/lib/rigor/configuration.rb +46 -2
  71. data/lib/rigor/environment/gated_signature_guard.rb +43 -0
  72. data/lib/rigor/environment/gated_signature_plan.rb +339 -0
  73. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  74. data/lib/rigor/environment/rbs_loader.rb +283 -14
  75. data/lib/rigor/environment/required_features.rb +206 -0
  76. data/lib/rigor/environment.rb +21 -3
  77. data/lib/rigor/inference/acceptance.rb +28 -3
  78. data/lib/rigor/inference/builtins/cruby_definers.rb +61 -0
  79. data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
  80. data/lib/rigor/inference/def_node_resolver.rb +23 -1
  81. data/lib/rigor/inference/expression_typer.rb +215 -16
  82. data/lib/rigor/inference/global_write_census.rb +7 -4
  83. data/lib/rigor/inference/guard_rebinding.rb +1 -1
  84. data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
  85. data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
  86. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +282 -0
  87. data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
  88. data/lib/rigor/inference/in_effect_refinements.rb +256 -0
  89. data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
  90. data/lib/rigor/inference/key_presence_guard.rb +303 -0
  91. data/lib/rigor/inference/macro_block_self_type.rb +59 -12
  92. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  93. data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
  94. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
  95. data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
  96. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
  97. data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
  98. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
  99. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
  100. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +259 -28
  101. data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +44 -11
  103. data/lib/rigor/inference/module_function_state.rb +2 -1
  104. data/lib/rigor/inference/narrowing.rb +20 -6
  105. data/lib/rigor/inference/object_mixins.rb +180 -0
  106. data/lib/rigor/inference/project_patched_methods.rb +10 -5
  107. data/lib/rigor/inference/project_patched_scanner.rb +71 -3
  108. data/lib/rigor/inference/refine_census.rb +239 -0
  109. data/lib/rigor/inference/refine_self.rb +317 -0
  110. data/lib/rigor/inference/refined_dispatch/own_method.rb +403 -0
  111. data/lib/rigor/inference/refined_dispatch.rb +272 -0
  112. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +13 -0
  113. data/lib/rigor/inference/scope_indexer.rb +687 -60
  114. data/lib/rigor/inference/statement_evaluator.rb +71 -20
  115. data/lib/rigor/inference/string_mutation.rb +9 -3
  116. data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
  117. data/lib/rigor/inference/version_guard.rb +19 -10
  118. data/lib/rigor/language_server/project_context.rb +31 -0
  119. data/lib/rigor/language_server/server.rb +18 -0
  120. data/lib/rigor/plugin/inflector.rb +1 -1
  121. data/lib/rigor/plugin/io_boundary.rb +42 -3
  122. data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
  123. data/lib/rigor/project_environment.rb +29 -6
  124. data/lib/rigor/protection/mutation_cache.rb +1 -1
  125. data/lib/rigor/protection/mutator.rb +1 -1
  126. data/lib/rigor/reflection/constant_ancestors.rb +127 -11
  127. data/lib/rigor/reflection/constant_path.rb +13 -2
  128. data/lib/rigor/reflection.rb +40 -8
  129. data/lib/rigor/scope/discovery_index.rb +40 -1
  130. data/lib/rigor/scope/resolution_chain.rb +159 -11
  131. data/lib/rigor/scope.rb +189 -10
  132. data/lib/rigor/sig_gen/classification.rb +4 -1
  133. data/lib/rigor/sig_gen/generator.rb +55 -5
  134. data/lib/rigor/sig_gen/method_candidate.rb +2 -2
  135. data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
  136. data/lib/rigor/source/alias_names.rb +52 -0
  137. data/lib/rigor/source/literals.rb +3 -2
  138. data/lib/rigor/source/symbol_name.rb +20 -0
  139. data/lib/rigor/type/combinator.rb +34 -2
  140. data/lib/rigor/type/float_range.rb +4 -1
  141. data/lib/rigor/type/nominal.rb +6 -1
  142. data/lib/rigor/version.rb +1 -1
  143. data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
  144. data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
  145. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
  146. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
  147. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
  148. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
  149. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
  150. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
  151. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
  152. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
  153. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
  154. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
  156. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
  157. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
  158. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
  159. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
  160. data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
  161. data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
  162. data/plugins/rigor-alba/sig/alba.rbs +4 -0
  163. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
  164. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
  165. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +7 -6
  166. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
  167. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
  168. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
  169. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
  170. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
  171. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
  172. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
  173. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
  174. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
  175. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
  176. data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
  177. data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
  178. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  179. data/sig/rigor/scope.rbs +27 -4
  180. data/sig/rigor/source.rbs +4 -0
  181. data/sig/rigor.rbs +3 -1
  182. metadata +66 -1
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require "rigor/inference/declaration_walk"
6
+
7
+ require_relative "resource_index"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ class Alba < Rigor::Plugin::Base
12
+ # A {Rigor::Inference::DeclarationWalk} collector over one file: the classes it declares, whether each
13
+ # `include Alba::Resource`s, and the association declarations whose resource
14
+ # class alba infers from the name.
15
+ #
16
+ # An association is recorded only when alba would take the inference path (`Alba::Association#
17
+ # resource_from`): a literal Symbol/String name, exactly one positional argument, no block, and no
18
+ # `resource:` / `serializer:` keyword. A keyword splat (`**opts`) might carry either, so it is not
19
+ # recorded either — a skipped association costs a root, never invents one.
20
+ class ResourceCollector
21
+ include Rigor::Inference::DeclarationWalk::Collector
22
+
23
+ ASSOCIATION_METHODS = %i[association one many has_one has_many].freeze
24
+ RESOURCE_MODULE = "Alba::Resource"
25
+ RESOURCE_KEYWORDS = %i[resource serializer].freeze
26
+ # The calls whose block alba `class_eval`s on an anonymous class (`name == nil`): an association
27
+ # lookup inside one sees no nesting. Any other block (`each`, `class_eval`, ...) leaves it alone.
28
+ ANONYMOUS_CLASS_BLOCK_METHODS = (ASSOCIATION_METHODS + %i[trait nested nested_attribute]).freeze
29
+ # `Alba.serialize(x) { ... }` / `Alba.hashify` / `Alba.resource_class` evaluate their block on an anonymous
30
+ # class too, wherever they are written — including inside a namespaced resource.
31
+ ALBA_ANONYMOUS_BLOCK_METHODS = %i[serialize hashify resource_class].freeze
32
+
33
+ attr_reader :classes, :associations
34
+
35
+ def initialize
36
+ @entries = {}
37
+ @includes = {}
38
+ @nestings = {}
39
+ @blocks = []
40
+ @associations = []
41
+ end
42
+
43
+ def on_declaration(node, context, body_context)
44
+ return Rigor::Inference::DeclarationWalk::DESCEND unless node.is_a?(Prism::ClassNode)
45
+
46
+ name = body_context.prefix.join("::")
47
+ unless name.empty?
48
+ @entries[name] = superclass_name(node.superclass)
49
+ @nestings[name] = context.nesting || []
50
+ end
51
+ Rigor::Inference::DeclarationWalk::DESCEND
52
+ end
53
+
54
+ def on_call(node, context)
55
+ owner = context.prefix.join("::")
56
+ record_call(node, owner) unless owner.empty? || !node.receiver.nil?
57
+ remember_block(node)
58
+ Rigor::Inference::DeclarationWalk::DESCEND
59
+ end
60
+
61
+ # The classes this file declares, as {ResourceIndex::ClassEntry} values.
62
+ def class_entries
63
+ @entries.map do |name, superclass|
64
+ ResourceIndex::ClassEntry.new(
65
+ name: name, superclass: superclass, nesting: @nestings.fetch(name),
66
+ includes_resource: @includes.fetch(name, false)
67
+ )
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def record_call(node, owner)
74
+ if node.name == :include
75
+ @includes[owner] = true if includes_resource?(node)
76
+ elsif ASSOCIATION_METHODS.include?(node.name)
77
+ name = inferred_association_name(node)
78
+ @associations << ResourceIndex::Association.new(owner: owner, name: name, in_block: in_block?(node)) if name
79
+ end
80
+ end
81
+
82
+ # The calls are visited outer-first, so a call's block is known before the calls inside it.
83
+ def remember_block(node)
84
+ block = node.block
85
+ return unless block.is_a?(Prism::BlockNode)
86
+ return unless ANONYMOUS_CLASS_BLOCK_METHODS.include?(node.name) || alba_anonymous_block_call?(node)
87
+
88
+ @blocks << (block.location.start_offset...block.location.end_offset)
89
+ end
90
+
91
+ def alba_anonymous_block_call?(node)
92
+ ALBA_ANONYMOUS_BLOCK_METHODS.include?(node.name) &&
93
+ constant_name(node.receiver)&.delete_prefix("::") == "Alba"
94
+ end
95
+
96
+ def in_block?(node)
97
+ offset = node.location.start_offset
98
+ @blocks.any? { |range| range.cover?(offset) }
99
+ end
100
+
101
+ def includes_resource?(node)
102
+ arguments = node.arguments&.arguments || []
103
+ arguments.any? { |argument| constant_name(argument)&.delete_prefix("::") == RESOURCE_MODULE }
104
+ end
105
+
106
+ def inferred_association_name(node)
107
+ return nil unless node.block.nil?
108
+
109
+ arguments = node.arguments&.arguments || []
110
+ positional = arguments.grep_v(Prism::KeywordHashNode)
111
+ keywords = arguments.grep(Prism::KeywordHashNode)
112
+ return nil unless positional.size == 1 && keywords.size <= 1
113
+ return nil if keywords.any? { |hash| names_resource?(hash) }
114
+
115
+ literal_name(positional.first)
116
+ end
117
+
118
+ def names_resource?(hash)
119
+ hash.elements.any? do |element|
120
+ !element.is_a?(Prism::AssocNode) || !element.key.is_a?(Prism::SymbolNode) ||
121
+ RESOURCE_KEYWORDS.include?(element.key.unescaped.to_sym)
122
+ end
123
+ end
124
+
125
+ def literal_name(node)
126
+ case node
127
+ when Prism::SymbolNode, Prism::StringNode then node.unescaped
128
+ end
129
+ end
130
+
131
+ def superclass_name(node)
132
+ node && constant_name(node)
133
+ end
134
+
135
+ def constant_name(node)
136
+ case node
137
+ when Prism::ConstantReadNode then node.name.to_s
138
+ when Prism::ConstantPathNode
139
+ parent = node.parent
140
+ return "::#{node.name}" if parent.nil?
141
+
142
+ prefix = constant_name(parent)
143
+ prefix && "#{prefix}::#{node.name}"
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require "rigor/inference/declaration_walk"
6
+
7
+ require_relative "resource_collector"
8
+ require_relative "resource_index"
9
+
10
+ module Rigor
11
+ module Plugin
12
+ class Alba < Rigor::Plugin::Base
13
+ # Reads every `.rb` file under the configured search paths through the plugin's `IoBoundary`, runs a
14
+ # {ResourceCollector} over each and merges the results into one {ResourceIndex}.
15
+ class ResourceDiscoverer
16
+ def initialize(io_boundary:, search_paths:)
17
+ @io_boundary = io_boundary
18
+ @search_paths = search_paths
19
+ end
20
+
21
+ def discover
22
+ entries = []
23
+ associations = []
24
+ ruby_files_under(@search_paths).each do |path|
25
+ contents = read_safely(path)
26
+ next if contents.nil?
27
+
28
+ parsed = Prism.parse(contents)
29
+ next unless parsed.success?
30
+
31
+ collector = ResourceCollector.new
32
+ Rigor::Inference::DeclarationWalk.run(
33
+ parsed.value, [collector], Rigor::Inference::DeclarationWalk::Context.root(nesting: [])
34
+ )
35
+ entries.concat(collector.class_entries)
36
+ associations.concat(collector.associations)
37
+ end
38
+ ResourceIndex.new(classes: merge(entries), associations: associations)
39
+ end
40
+
41
+ private
42
+
43
+ # A class reopened across files: it is a resource if any opening says so.
44
+ def merge(entries)
45
+ entries.group_by(&:name).map do |name, openings|
46
+ headed = openings.find(&:superclass) || openings.first
47
+ ResourceIndex::ClassEntry.new(
48
+ name: name,
49
+ superclass: headed.superclass,
50
+ nesting: headed.nesting,
51
+ includes_resource: openings.any?(&:includes_resource)
52
+ )
53
+ end
54
+ end
55
+
56
+ def read_safely(path)
57
+ @io_boundary.read_file(path)
58
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
59
+ nil
60
+ end
61
+
62
+ def ruby_files_under(roots)
63
+ roots.flat_map do |root|
64
+ absolute = File.expand_path(root)
65
+ # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
66
+ next [] unless @io_boundary.directory?(absolute)
67
+
68
+ Dir.glob(File.join(absolute, "**", "*.rb"))
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class Alba < Rigor::Plugin::Base
6
+ # The project's alba resource classes, as the {ResourceCollector} found them. A frozen value object the
7
+ # `:resource_index` producer caches.
8
+ #
9
+ # One question is answered from it, and deliberately conservatively — a wrong "no" costs a
10
+ # missing root, a wrong "yes" costs a false positive or a hidden dead class:
11
+ #
12
+ # {#inferred_roots}: which classes alba's association inference would load, for associations that name
13
+ # neither `resource:` nor `serializer:` (see {ResourceCollector}).
14
+ class ResourceIndex
15
+ # `owner` is the enclosing class's full name, which alba hands to `Object.const_get` as `nesting`.
16
+ # `in_block` is true when the call sits in a block of the resource body (`trait`, `nested`, an
17
+ # association's own block): alba `class_eval`s those on an anonymous class, whose `name` is nil, so
18
+ # only the top-level candidates are tried.
19
+ Association = Data.define(:owner, :name, :in_block)
20
+
21
+ # A class the project declares. `superclass` is the constant as written (`nil` when absent) and
22
+ # `nesting` the `Module.nesting` chain (innermost first) its header was written under, which is where
23
+ # Ruby resolves that constant.
24
+ ClassEntry = Data.define(:name, :superclass, :nesting, :includes_resource)
25
+
26
+ # The resource-class suffixes alba's `infer_resource_class` tries, in order.
27
+ SUFFIXES = %w[Resource Serializer].freeze
28
+ VALID_ASSOCIATION_NAME = /\A[a-z_][a-z0-9_]*\z/
29
+
30
+ def initialize(classes:, associations:)
31
+ @classes = classes.to_h { |entry| [entry.name, entry] }.freeze
32
+ @associations = associations.freeze
33
+ freeze
34
+ end
35
+
36
+ def empty?
37
+ @classes.empty?
38
+ end
39
+
40
+ # The classes alba infers for the associations that name no resource, limited to
41
+ # those that exist in the project. The block receives each association name and returns alba's
42
+ # `classify` of it (the caller owns the inflector; ADR-39).
43
+ def inferred_roots(&classify)
44
+ roots = @associations.filter_map do |association|
45
+ next unless resource?(association.owner)
46
+
47
+ inferred_class(association, classify)
48
+ end
49
+ roots.uniq.sort
50
+ end
51
+
52
+ private
53
+
54
+ # Alba tries `<Nesting>::<X>Resource`, then the top-level one (`const_get` on a module looks in Object),
55
+ # and only then the same two for `<X>Serializer`. The first that exists is the one alba loads.
56
+ def inferred_class(association, classify)
57
+ return nil unless VALID_ASSOCIATION_NAME.match?(association.name)
58
+
59
+ base = classify.call(association.name)
60
+ return nil unless base.is_a?(String) && /\A[A-Z][A-Za-z0-9]*\z/.match?(base)
61
+
62
+ nesting = association.in_block ? "" : association.owner.rpartition("::").first
63
+ SUFFIXES.each do |suffix|
64
+ candidates = ["#{base}#{suffix}"]
65
+ candidates.unshift("#{nesting}::#{base}#{suffix}") unless nesting.empty?
66
+ found = candidates.find { |candidate| @classes.key?(candidate) }
67
+ return found if found
68
+ end
69
+ nil
70
+ end
71
+
72
+ def resource?(name)
73
+ resource_chain?(name, {})
74
+ end
75
+
76
+ def resource_chain?(name, seen)
77
+ entry = @classes[name]
78
+ return false if entry.nil? || seen[name]
79
+
80
+ seen[name] = true
81
+ return true if entry.includes_resource
82
+
83
+ parent = superclass_of(entry)
84
+ !parent.nil? && resource_chain?(parent, seen)
85
+ end
86
+
87
+ # Resolves the superclass as written the way Ruby does: against the `Module.nesting` chain of the header,
88
+ # innermost first, then the top level. A compact header (`class Admin::UserResource < Base`) does not put
89
+ # `Admin` on the chain, so `Base` is not looked up under it.
90
+ def superclass_of(entry)
91
+ written = entry.superclass
92
+ return nil if written.nil?
93
+
94
+ if written.start_with?("::")
95
+ rooted = written.delete_prefix("::")
96
+ return @classes.key?(rooted) ? rooted : nil
97
+ end
98
+
99
+ (entry.nesting.map { |scope| "#{scope}::#{written}" } + [written]).find { |name| @classes.key?(name) }
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "alba/resource_index"
6
+ require_relative "alba/resource_collector"
7
+ require_relative "alba/resource_discoverer"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ # rigor-alba — recognises the alba JSON serializer (https://github.com/okuramasafumi/alba) in an
12
+ # application that uses it. Two facets, each of which only removes a diagnostic or adds a root:
13
+ #
14
+ # 1. **Block self.** `Alba.serialize(obj) { attributes :id }` and `Alba.hashify(obj) { ... }` `class_eval`
15
+ # their block on an anonymous `Class.new { include Alba::Resource }`, so the DSL calls inside resolve
16
+ # against `singleton(Alba::Resource)` (alba's own sig declares `[self: singleton(Resource)]` from 4.0) instead of
17
+ # firing `call.unresolved-toplevel`.
18
+ # 2. **`rigor unused` roots.** `many :articles` with no `resource:` makes alba infer `ArticleResource` (or
19
+ # `ArticleSerializer`) through `Alba.inflector`; that name appears nowhere in source, so the class would
20
+ # be listed as unused. It is published as a root — and only when a class of that name exists.
21
+ #
22
+ # No return type is contributed. `Alba.serialize(obj)` runs the project's own `<Class>Resource#serialize`,
23
+ # which the project may override to return anything, and `hashify` / `to_h` / `as_json` return whatever the
24
+ # resource's attributes build.
25
+ #
26
+ # alba keeps its `sig/` in the repository but does not ship it in the gem, so in a user project the
27
+ # constant `Alba` is unknown. The bundled `sig/alba.rbs` therefore declares just the two namespaces the
28
+ # block-self matching needs (`Alba`, `Alba::Resource`), and both are `open_receivers` so no
29
+ # undeclared call on them is ever diagnosed.
30
+ class Alba < Rigor::Plugin::Base
31
+ manifest(
32
+ id: "alba",
33
+ target_gems: ["alba"],
34
+ version: "0.1.0",
35
+ description: "Types alba's inline-resource block self and roots inferred " \
36
+ "association resources for `rigor unused`.",
37
+ config_schema: {
38
+ "resource_search_paths" => { kind: :array, default: ["app"] }
39
+ },
40
+ signature_paths: ["sig"],
41
+ open_receivers: %w[Alba Alba::Resource],
42
+ produces: [:reachability_roots],
43
+ block_as_methods: [
44
+ Rigor::Plugin::Macro::BlockAsMethod.new(
45
+ receiver_constraint: "Alba",
46
+ method_names: %i[serialize hashify],
47
+ self_type: "singleton(Alba::Resource)"
48
+ )
49
+ ]
50
+ )
51
+
52
+ producer :resource_index, watch: -> { [[@resource_search_paths, "**/*.rb"]] } do |_params|
53
+ ResourceDiscoverer.new(io_boundary: io_boundary, search_paths: @resource_search_paths).discover
54
+ end
55
+
56
+ def init(_services)
57
+ @resource_search_paths = Array(config.fetch("resource_search_paths")).map(&:to_s)
58
+ end
59
+
60
+ # ADR-102 WD3 — the resource classes alba's association inference loads. Published only where the
61
+ # inferred name resolves to a class the project declares ({ResourceIndex#inferred_roots}); a name nothing
62
+ # matches contributes nothing, and an unavailable inflector (ADR-39: decline, never approximate) leaves
63
+ # the report as it was.
64
+ def prepare(services)
65
+ index = resource_index
66
+ return if index.nil?
67
+
68
+ roots = index.inferred_roots { |name| Rigor::Plugin::Inflector.classify(name) }
69
+ return if roots.empty?
70
+
71
+ services.fact_store.publish(plugin_id: manifest.id, name: :reachability_roots, value: roots)
72
+ rescue Rigor::Plugin::Inflector::Unavailable
73
+ nil
74
+ end
75
+
76
+ private
77
+
78
+ def resource_index
79
+ producer_value(:resource_index)
80
+ end
81
+ end
82
+
83
+ Rigor::Plugin.register(Alba)
84
+ end
85
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/alba"
@@ -0,0 +1,4 @@
1
+ module Alba
2
+ module Resource
3
+ end
4
+ end
@@ -13,7 +13,7 @@ module Rigor
13
13
  when Prism::SymbolNode
14
14
  node.unescaped.to_sym
15
15
  when Prism::StringNode
16
- node.unescaped.to_sym
16
+ Rigor::Source::Literals.symbol_or_string(node)
17
17
  when Prism::CallNode
18
18
  extract_symbol(node.receiver) if node.name == :to_sym && node.receiver
19
19
  end
@@ -24,7 +24,7 @@ module Rigor
24
24
  when Prism::SymbolNode
25
25
  node.unescaped.to_sym
26
26
  when Prism::StringNode
27
- node.unescaped.to_sym
27
+ Rigor::Source::Literals.symbol_or_string(node)
28
28
  when Prism::ConstantReadNode, Prism::ConstantPathNode
29
29
  node.slice.to_sym
30
30
  when Prism::CallNode
@@ -3,43 +3,60 @@
3
3
  module Rigor
4
4
  module Plugin
5
5
  class FFI < Base
6
+ # Issue #1652 — given the plugin's {Rigor::Plugin::IoBoundary} (`io:`), every read goes through it, so the
7
+ # files the target was detected from are dependency rows of every cache built on the run. The
8
+ # `--incremental` run-result slot and the plain run-result cache serve an unchanged run without loading
9
+ # this plugin, and see a target flip only through those rows. `io: nil` reads the filesystem directly.
6
10
  module TargetDetector
7
11
  module_function
8
12
 
13
+ EXTCONF_GLOB = "ext/**/extconf.rb"
14
+
9
15
  # Detects whether the project targets ffx or the classic ffi gem per WD6.
10
- def detect(root:, config: {})
16
+ def detect(root:, config: {}, io: nil)
11
17
  explicit = config["target"] || config[:target]
12
18
  return explicit.to_sym if explicit && explicit != "auto"
13
19
 
14
- return :ffx if extconf_uses_ffx?(root)
15
- return :ffx if lockfile_has_ffx?(root)
20
+ return :ffx if extconf_uses_ffx?(root, io)
21
+ return :ffx if lockfile_has_ffx?(root, io)
16
22
 
17
23
  :ffi
18
24
  end
19
25
 
20
- def extconf_uses_ffx?(root)
26
+ def extconf_uses_ffx?(root, io = nil)
21
27
  return false if root.nil?
22
28
 
23
- Dir.glob(File.join(root.to_s, "ext/**/extconf.rb")).any? do |extconf|
24
- File.read(extconf).include?("FFX.create_makefile")
29
+ extconfs = io ? io.glob(root.to_s, EXTCONF_GLOB) : Dir.glob(File.join(root.to_s, EXTCONF_GLOB))
30
+ extconfs.any? do |extconf|
31
+ read_text(extconf, io).include?("FFX.create_makefile")
25
32
  rescue SystemCallError
26
33
  false
27
34
  end
28
35
  end
29
36
 
30
- def lockfile_has_ffx?(root)
37
+ def lockfile_has_ffx?(root, io = nil)
31
38
  return false if root.nil?
32
39
 
33
40
  lockfile = File.join(root.to_s, "Gemfile.lock")
34
- return false unless File.file?(lockfile)
41
+ return false unless io ? io.file?(lockfile) : File.file?(lockfile)
35
42
 
36
- File.foreach(lockfile) do |line|
37
- return true if line =~ /^\s{4}ffx\s/
38
- end
39
- false
43
+ read_text(lockfile, io).each_line.any? { |line| line.match?(/^\s{4}ffx\s/) }
40
44
  rescue SystemCallError
41
45
  false
42
46
  end
47
+
48
+ # A path outside the boundary's trust policy is refused there and read directly here: the target does
49
+ # not depend on the policy, and the boundary has noted the refusal (ADR-2 accepts that such a read
50
+ # leaves no row). Only a root outside every read root reaches it: a `root:` configured elsewhere, or a
51
+ # path spelled through a symlink (#959). Each extconf glob match is read here, not just listed: the
52
+ # glob's row records membership only, and this read's row carries an edit.
53
+ def read_text(path, io)
54
+ return File.read(path) if io.nil?
55
+
56
+ io.read_file(path)
57
+ rescue Rigor::Plugin::AccessDeniedError
58
+ File.read(path)
59
+ end
43
60
  end
44
61
  end
45
62
  end
@@ -127,21 +127,22 @@ module Rigor
127
127
 
128
128
  def init(_services)
129
129
  root = config["root"] || Dir.pwd
130
- @target = TargetDetector.detect(root: root, config: config)
130
+ @target = TargetDetector.detect(root: root, config: config, io: io_boundary)
131
131
  @exceptions = config["exceptions"] || []
132
132
  end
133
133
 
134
134
  def prepare(_services)
135
135
  root = config["root"] || Dir.pwd
136
- @target = TargetDetector.detect(root: root, config: config)
136
+ @target = TargetDetector.detect(root: root, config: config, io: io_boundary)
137
137
  @exceptions = config["exceptions"] || []
138
138
  end
139
139
 
140
140
  # ADR-88 channel (c) — the detected `:ffx` / `:ffi` target decides whether the `plugin.ffi.ffx.unsupported-*`
141
- # rules fire, and {TargetDetector} reads `<root>/Gemfile.lock` and `ext/**/extconf.rb` directly, not the
142
- # lockfile `bundler.lockfile:` resolves to — so neither the `--incremental` snapshot's gem-set digest nor an
143
- # edit to an analysed file covers a change in them. Declaring the target makes a flip send the warm
144
- # snapshot cold. Everything else this plugin contributes is per-file or comes from the `ffi_catalog`
141
+ # rules fire, and {TargetDetector} reads `<root>/Gemfile.lock` and `ext/**/extconf.rb`, not the lockfile
142
+ # `bundler.lockfile:` resolves to — so neither the `--incremental` snapshot's gem-set digest nor an edit to
143
+ # an analysed file covers a change in them. Declaring the target makes a flip send the warm snapshot cold.
144
+ # The run-result caches never load the plugin to ask; they see the flip through the boundary rows of those
145
+ # reads (#1652). Everything else this plugin contributes is per-file or comes from the `ffi_catalog`
145
146
  # producer (channel b).
146
147
  def incremental_state_fingerprint
147
148
  "target:#{@target || :ffi}"