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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/data/builtins/ruby_core/io.yml +1 -0
- data/data/builtins/ruby_core/proc.yml +4 -0
- data/data/builtins/ruby_core/random.yml +4 -0
- data/data/builtins/ruby_core/struct.yml +3 -0
- data/data/core_overlay/env.rbs +34 -0
- data/data/core_overlay/gc.rbs +50 -0
- data/data/core_overlay/hash.rbs +45 -0
- data/data/core_overlay/integer.rbs +31 -0
- data/data/core_overlay/io_buffer.rbs +27 -0
- data/data/core_overlay/kernel.rbs +40 -0
- data/data/core_overlay/match_data.rbs +28 -0
- data/data/core_overlay/module.rbs +51 -0
- data/data/core_overlay/object_space.rbs +29 -0
- data/data/core_overlay/proc.rbs +45 -0
- data/data/core_overlay/range.rbs +40 -0
- data/data/core_overlay/source_range.rbs +78 -0
- data/data/core_overlay/string.rbs +86 -0
- data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
- data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
- data/docs/handbook/01-getting-started.md +3 -3
- data/docs/handbook/06-classes.md +3 -1
- data/docs/handbook/11-sig-gen.md +6 -0
- data/docs/handbook/appendix-liskov.md +5 -0
- data/docs/manual/03-configuration.md +1 -1
- data/docs/manual/04-diagnostics.md +2 -1
- data/docs/manual/06-baseline.md +2 -0
- data/docs/manual/07-plugins.md +1 -1
- data/docs/manual/18-removing-dead-code.md +32 -2
- data/docs/manual/plugins/README.md +6 -1
- data/docs/manual/plugins/rigor-activerecord.md +21 -0
- data/docs/manual/plugins/rigor-alba.md +60 -0
- data/docs/manual/plugins/rigor-minitest.md +22 -4
- data/docs/manual/plugins/rigor-typelizer.md +71 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
- data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +4 -0
- data/lib/rigor/analysis/check_rules/source_arity.rb +21 -2
- data/lib/rigor/analysis/check_rules.rb +289 -57
- data/lib/rigor/analysis/dependency_recorder.rb +15 -0
- data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
- data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
- data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
- data/lib/rigor/analysis/erb_template_detector.rb +2 -1
- data/lib/rigor/analysis/incremental.rb +29 -9
- data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +30 -5
- data/lib/rigor/analysis/incremental_run_slot.rb +3 -1
- data/lib/rigor/analysis/incremental_session.rb +14 -1
- data/lib/rigor/analysis/reachability/graph.rb +123 -23
- data/lib/rigor/analysis/reachability/scan.rb +290 -36
- data/lib/rigor/analysis/rule_catalog.rb +43 -0
- data/lib/rigor/analysis/run_cache_key.rb +16 -1
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +87 -5
- data/lib/rigor/analysis/runner/pool_coordinator.rb +50 -11
- data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
- data/lib/rigor/analysis/runner.rb +34 -3
- data/lib/rigor/analysis/worker_session.rb +7 -4
- data/lib/rigor/cache/incremental_snapshot.rb +22 -0
- data/lib/rigor/cli/annotate_command.rb +5 -0
- data/lib/rigor/cli/baseline_command.rb +7 -18
- data/lib/rigor/cli/check_command.rb +2 -1
- data/lib/rigor/cli/trace_command.rb +5 -0
- data/lib/rigor/cli/type_of_command.rb +5 -3
- data/lib/rigor/cli/unused_command.rb +13 -7
- data/lib/rigor/cli.rb +10 -2
- data/lib/rigor/configuration/severity_profile.rb +6 -0
- data/lib/rigor/configuration.rb +46 -2
- data/lib/rigor/environment/gated_signature_guard.rb +43 -0
- data/lib/rigor/environment/gated_signature_plan.rb +339 -0
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +283 -14
- data/lib/rigor/environment/required_features.rb +206 -0
- data/lib/rigor/environment.rb +21 -3
- data/lib/rigor/inference/acceptance.rb +28 -3
- data/lib/rigor/inference/builtins/cruby_definers.rb +61 -0
- data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
- data/lib/rigor/inference/def_node_resolver.rb +23 -1
- data/lib/rigor/inference/expression_typer.rb +215 -16
- data/lib/rigor/inference/global_write_census.rb +7 -4
- data/lib/rigor/inference/guard_rebinding.rb +1 -1
- data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
- data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
- data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +282 -0
- data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
- data/lib/rigor/inference/in_effect_refinements.rb +256 -0
- data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
- data/lib/rigor/inference/key_presence_guard.rb +303 -0
- data/lib/rigor/inference/macro_block_self_type.rb +59 -12
- data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
- data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
- data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
- data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
- data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +259 -28
- data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
- data/lib/rigor/inference/method_dispatcher.rb +44 -11
- data/lib/rigor/inference/module_function_state.rb +2 -1
- data/lib/rigor/inference/narrowing.rb +20 -6
- data/lib/rigor/inference/object_mixins.rb +180 -0
- data/lib/rigor/inference/project_patched_methods.rb +10 -5
- data/lib/rigor/inference/project_patched_scanner.rb +71 -3
- data/lib/rigor/inference/refine_census.rb +239 -0
- data/lib/rigor/inference/refine_self.rb +317 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +403 -0
- data/lib/rigor/inference/refined_dispatch.rb +272 -0
- data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +13 -0
- data/lib/rigor/inference/scope_indexer.rb +687 -60
- data/lib/rigor/inference/statement_evaluator.rb +71 -20
- data/lib/rigor/inference/string_mutation.rb +9 -3
- data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
- data/lib/rigor/inference/version_guard.rb +19 -10
- data/lib/rigor/language_server/project_context.rb +31 -0
- data/lib/rigor/language_server/server.rb +18 -0
- data/lib/rigor/plugin/inflector.rb +1 -1
- data/lib/rigor/plugin/io_boundary.rb +42 -3
- data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
- data/lib/rigor/project_environment.rb +29 -6
- data/lib/rigor/protection/mutation_cache.rb +1 -1
- data/lib/rigor/protection/mutator.rb +1 -1
- data/lib/rigor/reflection/constant_ancestors.rb +127 -11
- data/lib/rigor/reflection/constant_path.rb +13 -2
- data/lib/rigor/reflection.rb +40 -8
- data/lib/rigor/scope/discovery_index.rb +40 -1
- data/lib/rigor/scope/resolution_chain.rb +159 -11
- data/lib/rigor/scope.rb +189 -10
- data/lib/rigor/sig_gen/classification.rb +4 -1
- data/lib/rigor/sig_gen/generator.rb +55 -5
- data/lib/rigor/sig_gen/method_candidate.rb +2 -2
- data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
- data/lib/rigor/source/alias_names.rb +52 -0
- data/lib/rigor/source/literals.rb +3 -2
- data/lib/rigor/source/symbol_name.rb +20 -0
- data/lib/rigor/type/combinator.rb +34 -2
- data/lib/rigor/type/float_range.rb +4 -1
- data/lib/rigor/type/nominal.rb +6 -1
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
- data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
- data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
- data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
- data/plugins/rigor-alba/sig/alba.rbs +4 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +7 -6
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
- data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
- data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
- data/sig/rigor/plugin/io_boundary.rbs +3 -0
- data/sig/rigor/scope.rbs +27 -4
- data/sig/rigor/source.rbs +4 -0
- data/sig/rigor.rbs +3 -1
- 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
|
|
@@ -13,7 +13,7 @@ module Rigor
|
|
|
13
13
|
when Prism::SymbolNode
|
|
14
14
|
node.unescaped.to_sym
|
|
15
15
|
when Prism::StringNode
|
|
16
|
-
node
|
|
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
|
|
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,
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
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}"
|