rigortype 0.1.3 → 0.1.4
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 +125 -31
- data/lib/rigor/analysis/check_rules.rb +10 -18
- data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +75 -0
- data/lib/rigor/analysis/dependency_source_inference/builder.rb +47 -21
- data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
- data/lib/rigor/analysis/dependency_source_inference/index.rb +32 -3
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +1 -1
- data/lib/rigor/analysis/dependency_source_inference.rb +1 -0
- data/lib/rigor/analysis/diagnostic.rb +0 -2
- data/lib/rigor/analysis/fact_store.rb +11 -3
- data/lib/rigor/analysis/rule_catalog.rb +2 -2
- data/lib/rigor/analysis/runner.rb +114 -3
- data/lib/rigor/builtins/imported_refinements.rb +360 -55
- data/lib/rigor/cache/descriptor.rb +1 -1
- data/lib/rigor/cache/store.rb +1 -1
- data/lib/rigor/cli/diff_command.rb +1 -1
- data/lib/rigor/cli/sig_gen_command.rb +173 -0
- data/lib/rigor/cli/type_of_command.rb +1 -1
- data/lib/rigor/cli/type_scan_renderer.rb +1 -1
- data/lib/rigor/cli/type_scan_report.rb +2 -2
- data/lib/rigor/cli.rb +9 -1
- data/lib/rigor/configuration/dependencies.rb +2 -2
- data/lib/rigor/configuration.rb +2 -2
- data/lib/rigor/environment.rb +35 -4
- data/lib/rigor/flow_contribution/conflict.rb +2 -2
- data/lib/rigor/flow_contribution/element.rb +1 -1
- data/lib/rigor/flow_contribution/fact.rb +1 -1
- data/lib/rigor/flow_contribution/merge_result.rb +1 -1
- data/lib/rigor/flow_contribution/merger.rb +3 -3
- data/lib/rigor/flow_contribution.rb +2 -2
- data/lib/rigor/inference/block_parameter_binder.rb +0 -2
- data/lib/rigor/inference/coverage_scanner.rb +1 -1
- data/lib/rigor/inference/expression_typer.rb +67 -11
- data/lib/rigor/inference/fallback.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +3 -5
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +0 -12
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -3
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +118 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -11
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +27 -11
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +0 -4
- data/lib/rigor/inference/method_dispatcher.rb +146 -2
- data/lib/rigor/inference/method_parameter_binder.rb +1 -3
- data/lib/rigor/inference/narrowing.rb +2 -4
- data/lib/rigor/inference/rbs_type_translator.rb +0 -2
- data/lib/rigor/inference/scope_indexer.rb +14 -9
- data/lib/rigor/inference/statement_evaluator.rb +7 -7
- data/lib/rigor/plugin/io_boundary.rb +0 -2
- data/lib/rigor/plugin/loader.rb +2 -2
- data/lib/rigor/plugin/manifest.rb +30 -9
- data/lib/rigor/plugin/registry.rb +11 -0
- data/lib/rigor/plugin/services.rb +1 -1
- data/lib/rigor/plugin/type_node_resolver.rb +52 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/reporter.rb +91 -0
- data/lib/rigor/rbs_extended.rb +131 -32
- data/lib/rigor/scope.rb +25 -8
- data/lib/rigor/sig_gen/classification.rb +36 -0
- data/lib/rigor/sig_gen/generator.rb +1048 -0
- data/lib/rigor/sig_gen/layout_index.rb +108 -0
- data/lib/rigor/sig_gen/method_candidate.rb +62 -0
- data/lib/rigor/sig_gen/observation_collector.rb +391 -0
- data/lib/rigor/sig_gen/observed_call.rb +62 -0
- data/lib/rigor/sig_gen/path_mapper.rb +116 -0
- data/lib/rigor/sig_gen/renderer.rb +157 -0
- data/lib/rigor/sig_gen/type_elaborator.rb +92 -0
- data/lib/rigor/sig_gen/write_result.rb +48 -0
- data/lib/rigor/sig_gen/writer.rb +530 -0
- data/lib/rigor/sig_gen.rb +25 -0
- data/lib/rigor/type/bound_method.rb +79 -0
- data/lib/rigor/type/combinator.rb +195 -2
- data/lib/rigor/type/constant.rb +13 -0
- data/lib/rigor/type/hash_shape.rb +0 -2
- data/lib/rigor/type/union.rb +20 -1
- data/lib/rigor/type.rb +1 -0
- data/lib/rigor/type_node/generic.rb +62 -0
- data/lib/rigor/type_node/identifier.rb +30 -0
- data/lib/rigor/type_node/indexed_access.rb +41 -0
- data/lib/rigor/type_node/integer_literal.rb +29 -0
- data/lib/rigor/type_node/name_scope.rb +52 -0
- data/lib/rigor/type_node/resolver_chain.rb +56 -0
- data/lib/rigor/type_node/string_literal.rb +29 -0
- data/lib/rigor/type_node/symbol_literal.rb +28 -0
- data/lib/rigor/type_node/union.rb +42 -0
- data/lib/rigor/type_node.rb +29 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +2 -0
- data/sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs +10 -0
- data/sig/rigor/analysis/check_rules/dead_assignment_collector.rbs +10 -0
- data/sig/rigor/analysis/dependency_source_inference/gem_resolver.rbs +25 -0
- data/sig/rigor/analysis/dependency_source_inference/index.rbs +9 -0
- data/sig/rigor/cli/diff_command.rbs +4 -0
- data/sig/rigor/cli/explain_command.rbs +4 -0
- data/sig/rigor/cli/sig_gen_command.rbs +4 -0
- data/sig/rigor/cli/type_scan_command.rbs +3 -0
- data/sig/rigor/environment.rbs +5 -2
- data/sig/rigor/inference/builtins/method_catalog.rbs +4 -0
- data/sig/rigor/inference/builtins/numeric_catalog.rbs +3 -0
- data/sig/rigor/inference/builtins.rbs +2 -0
- data/sig/rigor/plugin/access_denied_error.rbs +3 -0
- data/sig/rigor/plugin/base.rbs +6 -0
- data/sig/rigor/plugin/fact_store.rbs +11 -0
- data/sig/rigor/plugin/io_boundary.rbs +4 -0
- data/sig/rigor/plugin/load_error.rbs +6 -0
- data/sig/rigor/plugin/loader.rbs +20 -0
- data/sig/rigor/plugin/manifest.rbs +9 -0
- data/sig/rigor/plugin/registry.rbs +3 -0
- data/sig/rigor/plugin/services.rbs +3 -0
- data/sig/rigor/plugin/trust_policy.rbs +4 -0
- data/sig/rigor/plugin/type_node_resolver.rbs +3 -0
- data/sig/rigor/plugin.rbs +8 -0
- data/sig/rigor/scope.rbs +4 -2
- data/sig/rigor/type.rbs +28 -6
- metadata +52 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbs"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module SigGen
|
|
7
|
+
# Pre-scans every `.rbs` file under the configured
|
|
8
|
+
# `signature_paths` to build a `qualified_class_name →
|
|
9
|
+
# sig_file_path` map.
|
|
10
|
+
#
|
|
11
|
+
# ADR-14 path-mapper limitation surfaced repeatedly during
|
|
12
|
+
# the self-dogfood: the existing rigor `sig/` consolidates
|
|
13
|
+
# multiple `.rb` sources into one `.rbs` file (e.g.
|
|
14
|
+
# `sig/rigor/type.rbs` declares all 14 `Type::*` classes,
|
|
15
|
+
# `sig/rigor.rbs` declares `CLI::TypeOfCommand` and
|
|
16
|
+
# `CLI::TypeScanCommand`). The naive 1:1 mapper writes new
|
|
17
|
+
# files alongside the existing consolidated ones, producing
|
|
18
|
+
# `RBS::DuplicatedMethodDefinition` errors at lookup time.
|
|
19
|
+
#
|
|
20
|
+
# The index lets {PathMapper} route a candidate to the
|
|
21
|
+
# consolidated sig file when the class is already declared
|
|
22
|
+
# there, falling back to the 1:1 mirror only when the
|
|
23
|
+
# class has no existing declaration anywhere under the
|
|
24
|
+
# signature tree.
|
|
25
|
+
#
|
|
26
|
+
# First-found wins on duplicate declarations across files;
|
|
27
|
+
# RBS itself allows the same class to be declared in
|
|
28
|
+
# multiple files for additive member contributions, but
|
|
29
|
+
# the writer only needs one canonical target per class.
|
|
30
|
+
class LayoutIndex
|
|
31
|
+
# @param signature_paths [Array<String, Pathname>, nil]
|
|
32
|
+
# the `.rigor.yml`-configured signature directories.
|
|
33
|
+
# When `nil` or empty, falls back to `<project_root>/sig`
|
|
34
|
+
# if it exists (matching `Environment.for_project`'s
|
|
35
|
+
# auto-detection convention).
|
|
36
|
+
# @param project_root [String, Pathname]
|
|
37
|
+
def initialize(signature_paths:, project_root: Dir.pwd)
|
|
38
|
+
@signature_paths = resolve_paths(signature_paths, project_root)
|
|
39
|
+
@index = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param class_name [String] fully-qualified Ruby class
|
|
43
|
+
# name (e.g. `"Rigor::Type::Top"`).
|
|
44
|
+
# @return [Pathname, nil] absolute path of the sig file
|
|
45
|
+
# that already declares this class, or `nil` when no
|
|
46
|
+
# existing declaration is found.
|
|
47
|
+
def file_for(class_name)
|
|
48
|
+
index[class_name]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def empty?
|
|
52
|
+
index.empty?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def resolve_paths(configured, project_root)
|
|
58
|
+
list = Array(configured).reject { |p| p.nil? || p.to_s.empty? }
|
|
59
|
+
return list unless list.empty?
|
|
60
|
+
|
|
61
|
+
default = Pathname(project_root) / "sig"
|
|
62
|
+
default.directory? ? [default] : []
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def index
|
|
66
|
+
@index ||= build_index
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_index
|
|
70
|
+
accumulator = {}
|
|
71
|
+
Array(@signature_paths).each do |dir|
|
|
72
|
+
base = Pathname(dir)
|
|
73
|
+
next unless base.directory?
|
|
74
|
+
|
|
75
|
+
Dir.glob(File.join(base.to_s, "**/*.rbs"), sort: true).each do |rbs_file|
|
|
76
|
+
index_file(Pathname(rbs_file), accumulator)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
accumulator.freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def index_file(rbs_path, accumulator)
|
|
83
|
+
source = rbs_path.read
|
|
84
|
+
_, _, decls = RBS::Parser.parse_signature(source)
|
|
85
|
+
record_decls(decls, [], rbs_path, accumulator)
|
|
86
|
+
rescue StandardError
|
|
87
|
+
# Bad RBS file — skip silently; the user's `rigor
|
|
88
|
+
# check` run will surface the real parse error
|
|
89
|
+
# elsewhere.
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def record_decls(decls, prefix, rbs_path, accumulator)
|
|
93
|
+
decls.each { |decl| record_decl(decl, prefix, rbs_path, accumulator) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def record_decl(decl, prefix, rbs_path, accumulator)
|
|
97
|
+
return unless decl.is_a?(RBS::AST::Declarations::Class) ||
|
|
98
|
+
decl.is_a?(RBS::AST::Declarations::Module)
|
|
99
|
+
|
|
100
|
+
local_name = decl.name.to_s.sub(/\A::/, "")
|
|
101
|
+
full = prefix.empty? ? local_name : "#{prefix.join('::')}::#{local_name}"
|
|
102
|
+
accumulator[full] ||= rbs_path
|
|
103
|
+
|
|
104
|
+
record_decls(decl.members, prefix + [local_name], rbs_path, accumulator)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module SigGen
|
|
5
|
+
# Per-method record produced by the generator.
|
|
6
|
+
#
|
|
7
|
+
# `classification` is one of the {Classification} constants;
|
|
8
|
+
# the remaining fields are populated only when applicable
|
|
9
|
+
# to that classification.
|
|
10
|
+
#
|
|
11
|
+
# - `path` — the source `.rb` file the def came from.
|
|
12
|
+
# - `class_name` — qualified receiver class name (e.g.
|
|
13
|
+
# `"Foo::Bar"`). `nil` for top-level / DSL-block defs
|
|
14
|
+
# the MVP skips.
|
|
15
|
+
# - `method_name` — the def's `Symbol` name.
|
|
16
|
+
# - `kind` — `:instance` or `:singleton`.
|
|
17
|
+
# - `inferred_return` — `Rigor::Type` instance (or `nil`
|
|
18
|
+
# when the inference pass disqualified the def).
|
|
19
|
+
# - `declared_return_rbs` — the existing RBS-declared return
|
|
20
|
+
# spelling, or `nil` when no RBS declares the method.
|
|
21
|
+
# - `rbs` — the rendered RBS one-liner the generator would
|
|
22
|
+
# emit (`nil` for skipped / equivalent rows).
|
|
23
|
+
# - `skip_reason` — one of {Classification::SKIP_DIAGNOSTIC_IDS}
|
|
24
|
+
# keys when classification is `:skipped`, else `nil`.
|
|
25
|
+
class MethodCandidate
|
|
26
|
+
attr_reader :path, :class_name, :method_name, :kind, :classification,
|
|
27
|
+
:inferred_return, :declared_return_rbs, :rbs, :skip_reason,
|
|
28
|
+
:namespace_kinds, :class_shells
|
|
29
|
+
|
|
30
|
+
def initialize(path:, class_name:, method_name:, kind:, classification:, # rubocop:disable Metrics/ParameterLists
|
|
31
|
+
inferred_return: nil, declared_return_rbs: nil, rbs: nil, skip_reason: nil,
|
|
32
|
+
namespace_kinds: {}, class_shells: [])
|
|
33
|
+
@path = path
|
|
34
|
+
@class_name = class_name
|
|
35
|
+
@method_name = method_name
|
|
36
|
+
@kind = kind
|
|
37
|
+
@classification = classification
|
|
38
|
+
@inferred_return = inferred_return
|
|
39
|
+
@declared_return_rbs = declared_return_rbs
|
|
40
|
+
@rbs = rbs
|
|
41
|
+
@skip_reason = skip_reason
|
|
42
|
+
@namespace_kinds = namespace_kinds.freeze
|
|
43
|
+
@class_shells = class_shells.freeze
|
|
44
|
+
freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
{
|
|
49
|
+
file: path,
|
|
50
|
+
class: class_name,
|
|
51
|
+
method: method_name.to_s,
|
|
52
|
+
kind: kind.to_s,
|
|
53
|
+
classification: classification.to_s,
|
|
54
|
+
rbs: rbs,
|
|
55
|
+
inferred_return: inferred_return&.erase_to_rbs,
|
|
56
|
+
declared_return_rbs: declared_return_rbs,
|
|
57
|
+
skip_reason: skip_reason ? Classification::SKIP_DIAGNOSTIC_IDS.fetch(skip_reason) : nil
|
|
58
|
+
}.compact
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../environment"
|
|
6
|
+
require_relative "../scope"
|
|
7
|
+
require_relative "../type"
|
|
8
|
+
require_relative "../inference/scope_indexer"
|
|
9
|
+
|
|
10
|
+
module Rigor
|
|
11
|
+
module SigGen
|
|
12
|
+
# ADR-14 slice 3 — caller-side argument-type observation
|
|
13
|
+
# collector.
|
|
14
|
+
#
|
|
15
|
+
# Walks the user-supplied `--observe=PATH...` tree (default
|
|
16
|
+
# `spec/`), parses every `.rb` file with `Prism`, scope-
|
|
17
|
+
# indexes it the same way the main generator does, and
|
|
18
|
+
# records the per-call-site argument-type tuples for every
|
|
19
|
+
# `Prism::CallNode` whose receiver types as a
|
|
20
|
+
# `Type::Nominal`. The {Generator} consumes the resulting
|
|
21
|
+
# map to render `--params=observed` RBS:
|
|
22
|
+
#
|
|
23
|
+
# @return [Hash{[class_name, method_name] =>
|
|
24
|
+
# Array<Array<Rigor::Type>>}]
|
|
25
|
+
#
|
|
26
|
+
# ADR-5 clause 2 compliance: the observed union is the
|
|
27
|
+
# MOST PERMISSIVE parameter contract the existing callers
|
|
28
|
+
# prove sufficient — by construction it accepts every type
|
|
29
|
+
# any caller has already passed. The collector only
|
|
30
|
+
# surfaces the data; the default `--params=untyped` keeps
|
|
31
|
+
# the observation inert until the user opts in.
|
|
32
|
+
#
|
|
33
|
+
# MVP scope:
|
|
34
|
+
# - Explicit-receiver calls only (`foo.bar(args)`). Implicit-
|
|
35
|
+
# self calls inside class bodies and RSpec-style
|
|
36
|
+
# `let` / `subject` bindings ride on slice 5's optional
|
|
37
|
+
# `rigor-rspec` integration.
|
|
38
|
+
# - Calls whose receiver does not type as a `Type::Nominal`
|
|
39
|
+
# (e.g. `(some_dynamic).bar(...)`) are skipped — the
|
|
40
|
+
# collector cannot attribute them to a specific class.
|
|
41
|
+
# - Zero-argument calls give no observation; methods are
|
|
42
|
+
# matched by `(class_name, method_name)` only.
|
|
43
|
+
class ObservationCollector # rubocop:disable Metrics/ClassLength
|
|
44
|
+
# @param configuration [Rigor::Configuration]
|
|
45
|
+
# @param paths [Array<String>] observe paths (files /
|
|
46
|
+
# directories).
|
|
47
|
+
# @param source_paths [Array<String>] source-tree paths
|
|
48
|
+
# (defaults to `configuration.paths`) pre-walked to
|
|
49
|
+
# register every project-defined class so that calls
|
|
50
|
+
# like `Foo.new.bar(x)` in the observe tree resolve
|
|
51
|
+
# to a `Type::Nominal[Foo]` receiver instead of
|
|
52
|
+
# degrading to `Dynamic[top]` for the unknown
|
|
53
|
+
# constant.
|
|
54
|
+
def initialize(configuration:, paths:, source_paths: nil)
|
|
55
|
+
@configuration = configuration
|
|
56
|
+
@paths = paths
|
|
57
|
+
@source_paths = source_paths || configuration.paths
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def collect
|
|
61
|
+
return {} if @paths.empty?
|
|
62
|
+
|
|
63
|
+
environment = build_environment
|
|
64
|
+
discovered_classes = preindex_source_classes
|
|
65
|
+
observations = Hash.new { |h, k| h[k] = [] }
|
|
66
|
+
resolve_paths(@paths).each do |path|
|
|
67
|
+
collect_from_file(path, environment, discovered_classes, observations)
|
|
68
|
+
end
|
|
69
|
+
observations.transform_values(&:freeze).freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def build_environment
|
|
75
|
+
Environment.for_project(
|
|
76
|
+
libraries: @configuration.libraries,
|
|
77
|
+
signature_paths: @configuration.signature_paths
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def resolve_paths(args)
|
|
82
|
+
args.flat_map do |arg|
|
|
83
|
+
if File.directory?(arg)
|
|
84
|
+
Dir.glob(File.join(arg, "**/*.rb"), sort: true)
|
|
85
|
+
elsif File.file?(arg) && arg.end_with?(".rb")
|
|
86
|
+
[arg]
|
|
87
|
+
else
|
|
88
|
+
[]
|
|
89
|
+
end
|
|
90
|
+
end.uniq
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def collect_from_file(path, environment, discovered_classes, observations)
|
|
94
|
+
source = File.read(path)
|
|
95
|
+
parse_result = Prism.parse(source, filepath: path, version: @configuration.target_ruby)
|
|
96
|
+
return if parse_result.errors.any?
|
|
97
|
+
|
|
98
|
+
base_scope = Scope.empty(environment: environment).with_discovered_classes(discovered_classes)
|
|
99
|
+
scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
|
|
100
|
+
bindings = collect_rspec_bindings(parse_result.value, scope_index)
|
|
101
|
+
|
|
102
|
+
walk_calls(parse_result.value, scope_index, bindings, observations)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Pre-walks `@source_paths` to collect every qualified
|
|
106
|
+
# class / module declaration. The result feeds
|
|
107
|
+
# `Scope#with_discovered_classes` for each observe-tree
|
|
108
|
+
# scope so `Foo.new` and `Foo` resolve to the right
|
|
109
|
+
# singleton carrier even when no RBS sig describes
|
|
110
|
+
# `Foo` yet.
|
|
111
|
+
def preindex_source_classes
|
|
112
|
+
accumulator = {}
|
|
113
|
+
resolve_paths(@source_paths).each { |path| harvest_classes_from(path, accumulator) }
|
|
114
|
+
accumulator.freeze
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def harvest_classes_from(path, accumulator)
|
|
118
|
+
source = File.read(path)
|
|
119
|
+
parse_result = Prism.parse(source, filepath: path, version: @configuration.target_ruby)
|
|
120
|
+
return if parse_result.errors.any?
|
|
121
|
+
|
|
122
|
+
walk_class_decls(parse_result.value, [], accumulator)
|
|
123
|
+
rescue StandardError
|
|
124
|
+
# Source-side harvest failures are tolerated silently
|
|
125
|
+
# — the collector still runs on whichever files
|
|
126
|
+
# parsed cleanly.
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def walk_class_decls(node, prefix, accumulator)
|
|
130
|
+
return unless node.is_a?(Prism::Node)
|
|
131
|
+
|
|
132
|
+
if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
|
|
133
|
+
name = qualified_constant_path(node.constant_path)
|
|
134
|
+
if name
|
|
135
|
+
full = (prefix + [name]).join("::")
|
|
136
|
+
accumulator[full] = Type::Combinator.singleton_of(full)
|
|
137
|
+
walk_class_decls(node.body, prefix + [name], accumulator) if node.body
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
node.compact_child_nodes.each { |child| walk_class_decls(child, prefix, accumulator) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def qualified_constant_path(constant_path)
|
|
146
|
+
case constant_path
|
|
147
|
+
when Prism::ConstantReadNode
|
|
148
|
+
constant_path.name.to_s
|
|
149
|
+
when Prism::ConstantPathNode
|
|
150
|
+
parent = qualified_constant_path(constant_path.parent) if constant_path.parent
|
|
151
|
+
name = constant_path.name&.to_s
|
|
152
|
+
return nil if name.nil?
|
|
153
|
+
|
|
154
|
+
parent ? "#{parent}::#{name}" : name
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def walk_calls(node, scope_index, bindings, observations)
|
|
159
|
+
return unless node.is_a?(Prism::Node)
|
|
160
|
+
|
|
161
|
+
record_call(node, scope_index, bindings, observations) if node.is_a?(Prism::CallNode)
|
|
162
|
+
node.compact_child_nodes.each { |child| walk_calls(child, scope_index, bindings, observations) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def record_call(call_node, scope_index, bindings, observations)
|
|
166
|
+
receiver = call_node.receiver
|
|
167
|
+
return if receiver.nil?
|
|
168
|
+
|
|
169
|
+
scope = scope_index[call_node] || scope_index[receiver]
|
|
170
|
+
return if scope.nil?
|
|
171
|
+
|
|
172
|
+
receiver_type = resolve_receiver_type(receiver, scope, bindings)
|
|
173
|
+
key = observation_key(call_node, receiver_type)
|
|
174
|
+
return if key.nil?
|
|
175
|
+
|
|
176
|
+
observation = collect_args(call_node, scope)
|
|
177
|
+
return if observation.nil? || observation.empty?
|
|
178
|
+
|
|
179
|
+
observations[key] << observation
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# ADR-14 follow-up (A): `.new` → `:initialize` routing.
|
|
183
|
+
# `MethodCatalog.new(path: ...)` types its receiver as
|
|
184
|
+
# `Type::Singleton[MethodCatalog]` and its call name as
|
|
185
|
+
# `:new`, but the *implicit* effect at runtime is a call
|
|
186
|
+
# to `MethodCatalog#initialize(path: ...)`. Route the
|
|
187
|
+
# observation under `[class_name, :initialize]` so the
|
|
188
|
+
# initialize-stub renderer can consult it.
|
|
189
|
+
def observation_key(call_node, receiver_type)
|
|
190
|
+
if receiver_type.is_a?(Type::Singleton) && call_node.name == :new
|
|
191
|
+
[receiver_type.class_name, :initialize]
|
|
192
|
+
elsif receiver_type.is_a?(Type::Nominal)
|
|
193
|
+
[receiver_type.class_name, call_node.name]
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# ADR-14 slice 5 — RSpec-aware receiver typing.
|
|
198
|
+
# Resolves a CallNode receiver against the collected
|
|
199
|
+
# `bindings` map (built by {#collect_rspec_bindings})
|
|
200
|
+
# before falling back to ordinary `scope.type_of`. The
|
|
201
|
+
# three RSpec-shaped receivers we recognise:
|
|
202
|
+
#
|
|
203
|
+
# - Bare-name CallNode (`subject`, `other`, ...) whose
|
|
204
|
+
# name matches a `subject` / `let(:name)` binding —
|
|
205
|
+
# return the binding's recorded type.
|
|
206
|
+
# - `described_class.new(...)` chain — when the
|
|
207
|
+
# surrounding `describe Foo do … end` resolved `Foo`,
|
|
208
|
+
# return `Type::Nominal[Foo]`.
|
|
209
|
+
# - Anything else — pass through to `scope.type_of`,
|
|
210
|
+
# matching slice-3 behaviour.
|
|
211
|
+
def resolve_receiver_type(receiver, scope, bindings)
|
|
212
|
+
return resolve_described_class_new(bindings) if described_class_new?(receiver)
|
|
213
|
+
return bindings[receiver.name] if bound_call?(receiver, bindings)
|
|
214
|
+
|
|
215
|
+
safe_type_of(scope, receiver)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def bound_call?(receiver, bindings)
|
|
219
|
+
simple_no_arg_call?(receiver) && bindings.key?(receiver.name)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def described_class_new?(node)
|
|
223
|
+
return false unless node.is_a?(Prism::CallNode) && node.name == :new
|
|
224
|
+
|
|
225
|
+
described_class_reference?(node.receiver)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def described_class_reference?(node)
|
|
229
|
+
return false unless node.is_a?(Prism::CallNode) && node.name == :described_class
|
|
230
|
+
|
|
231
|
+
node.receiver.nil? && (node.arguments&.arguments || []).empty?
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def resolve_described_class_new(bindings)
|
|
235
|
+
singleton = bindings[:described_class]
|
|
236
|
+
return nil unless singleton.is_a?(Type::Singleton)
|
|
237
|
+
|
|
238
|
+
Type::Combinator.nominal_of(singleton.class_name)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def simple_no_arg_call?(node)
|
|
242
|
+
node.is_a?(Prism::CallNode) &&
|
|
243
|
+
node.receiver.nil? &&
|
|
244
|
+
(node.arguments&.arguments || []).empty? &&
|
|
245
|
+
node.block.nil?
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Walks the spec file for `describe X do … end` /
|
|
249
|
+
# `RSpec.describe X do … end` blocks plus the
|
|
250
|
+
# `subject` / `let(:name)` declarations inside them.
|
|
251
|
+
# Returns a flat map `{ binding_name (Symbol) => Type }`
|
|
252
|
+
# plus a synthetic `:described_class` slot keyed off
|
|
253
|
+
# the nearest enclosing `describe`.
|
|
254
|
+
#
|
|
255
|
+
# The recogniser is intentionally lightweight: it does
|
|
256
|
+
# not enforce RSpec scope rules across `describe` /
|
|
257
|
+
# `context` blocks. Nested `describe` declarations
|
|
258
|
+
# overwrite the outer `described_class` for the
|
|
259
|
+
# remainder of the walk; same-name `let` bindings are
|
|
260
|
+
# last-wins. This matches the typical one-spec-file
|
|
261
|
+
# shape ADR-14 slice 5 targets without re-implementing
|
|
262
|
+
# the `rigor-rspec` plugin's full scope analyser.
|
|
263
|
+
def collect_rspec_bindings(root, scope_index)
|
|
264
|
+
bindings = {}
|
|
265
|
+
walk_rspec_bindings(root, bindings, scope_index)
|
|
266
|
+
bindings
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def walk_rspec_bindings(node, bindings, scope_index)
|
|
270
|
+
return unless node.is_a?(Prism::Node)
|
|
271
|
+
|
|
272
|
+
recognise_describe(node, bindings)
|
|
273
|
+
recognise_subject_or_let(node, bindings, scope_index)
|
|
274
|
+
|
|
275
|
+
node.compact_child_nodes.each { |child| walk_rspec_bindings(child, bindings, scope_index) }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def recognise_describe(node, bindings)
|
|
279
|
+
return unless describe_call?(node)
|
|
280
|
+
|
|
281
|
+
constant_arg = node.arguments&.arguments&.first
|
|
282
|
+
name = qualified_constant_path(constant_arg) if constant_arg
|
|
283
|
+
bindings[:described_class] = Type::Combinator.singleton_of(name) if name
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def describe_call?(node)
|
|
287
|
+
return false unless node.is_a?(Prism::CallNode) && node.name == :describe
|
|
288
|
+
|
|
289
|
+
receiver = node.receiver
|
|
290
|
+
receiver.nil? || (receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :RSpec)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
RSPEC_BINDING_METHODS = %i[subject let let!].freeze
|
|
294
|
+
private_constant :RSPEC_BINDING_METHODS
|
|
295
|
+
|
|
296
|
+
def recognise_subject_or_let(node, bindings, scope_index)
|
|
297
|
+
return unless node.is_a?(Prism::CallNode) && RSPEC_BINDING_METHODS.include?(node.name)
|
|
298
|
+
return if node.block.nil?
|
|
299
|
+
|
|
300
|
+
name = binding_name_for(node)
|
|
301
|
+
return if name.nil?
|
|
302
|
+
|
|
303
|
+
body_type = type_block_body(node.block, scope_index)
|
|
304
|
+
bindings[name] = body_type if body_type
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def binding_name_for(call_node)
|
|
308
|
+
first_arg = call_node.arguments&.arguments&.first
|
|
309
|
+
return call_node.name == :subject ? :subject : nil if first_arg.nil?
|
|
310
|
+
return first_arg.unescaped.to_sym if first_arg.is_a?(Prism::SymbolNode) || first_arg.is_a?(Prism::StringNode)
|
|
311
|
+
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def type_block_body(block_node, scope_index)
|
|
316
|
+
body = block_body_node(block_node)
|
|
317
|
+
return nil if body.nil?
|
|
318
|
+
|
|
319
|
+
last_expr = body_last_expression(body)
|
|
320
|
+
return nil if last_expr.nil?
|
|
321
|
+
|
|
322
|
+
scope = scope_index[last_expr] || scope_index[block_node]
|
|
323
|
+
return nil if scope.nil?
|
|
324
|
+
|
|
325
|
+
safe_type_of(scope, last_expr)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def block_body_node(block_node)
|
|
329
|
+
return nil unless block_node.is_a?(Prism::BlockNode)
|
|
330
|
+
|
|
331
|
+
block_node.body
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def body_last_expression(body)
|
|
335
|
+
case body
|
|
336
|
+
when Prism::StatementsNode then body.body.last
|
|
337
|
+
when Prism::BeginNode then body_last_expression(body.statements)
|
|
338
|
+
else body
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# ADR-14 follow-up (B): walks a call's argument list
|
|
343
|
+
# separating positional from keyword arguments and
|
|
344
|
+
# returning an {ObservedCall} carrier. Splat /
|
|
345
|
+
# forwarded / block arguments still abort the
|
|
346
|
+
# observation (`nil`) — those don't map cleanly to a
|
|
347
|
+
# single per-position type the renderer can union.
|
|
348
|
+
def collect_args(call_node, scope)
|
|
349
|
+
positional = []
|
|
350
|
+
keyword = {}
|
|
351
|
+
args = call_node.arguments&.arguments || []
|
|
352
|
+
args.each do |arg|
|
|
353
|
+
case arg
|
|
354
|
+
when Prism::KeywordHashNode
|
|
355
|
+
pairs = read_keyword_pairs(arg, scope)
|
|
356
|
+
return nil if pairs.nil?
|
|
357
|
+
|
|
358
|
+
keyword.merge!(pairs)
|
|
359
|
+
when Prism::SplatNode, Prism::BlockArgumentNode, Prism::ForwardingArgumentsNode
|
|
360
|
+
return nil
|
|
361
|
+
else
|
|
362
|
+
type = safe_type_of(scope, arg)
|
|
363
|
+
return nil if type.nil?
|
|
364
|
+
|
|
365
|
+
positional << type
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
ObservedCall.new(positional: positional, keyword: keyword)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def read_keyword_pairs(hash_node, scope)
|
|
372
|
+
out = {}
|
|
373
|
+
hash_node.elements.each do |pair|
|
|
374
|
+
return nil unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
|
|
375
|
+
|
|
376
|
+
type = safe_type_of(scope, pair.value)
|
|
377
|
+
return nil if type.nil?
|
|
378
|
+
|
|
379
|
+
out[pair.key.unescaped.to_sym] = type
|
|
380
|
+
end
|
|
381
|
+
out
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def safe_type_of(scope, node)
|
|
385
|
+
scope.type_of(node)
|
|
386
|
+
rescue StandardError
|
|
387
|
+
nil
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module SigGen
|
|
5
|
+
# Per-call-site argument observation produced by
|
|
6
|
+
# {ObservationCollector}. ADR-14 follow-up: the earlier
|
|
7
|
+
# MVP shape (`Array[Type]` of positional types only)
|
|
8
|
+
# could not represent keyword arguments — every call like
|
|
9
|
+
# `MethodCatalog.new(path: ..., mutating_selectors: ...)`
|
|
10
|
+
# discarded the whole observation via `non_positional?`.
|
|
11
|
+
# The new shape carries positional and keyword arg types
|
|
12
|
+
# in parallel so the per-position / per-keyword unions
|
|
13
|
+
# can each be reconstructed independently.
|
|
14
|
+
#
|
|
15
|
+
# The carrier is intentionally minimal:
|
|
16
|
+
# - `positional` — frozen Array of `Rigor::Type` per
|
|
17
|
+
# positional argument, in call-site order.
|
|
18
|
+
# - `keyword` — frozen Hash mapping each keyword
|
|
19
|
+
# argument's Symbol name to its `Rigor::Type`.
|
|
20
|
+
#
|
|
21
|
+
# Generator-side callers also accept a legacy shape
|
|
22
|
+
# (plain Array of types) for backward compatibility with
|
|
23
|
+
# specs that constructed observations directly before
|
|
24
|
+
# this carrier existed; `ObservedCall.from(...)` does the
|
|
25
|
+
# lift.
|
|
26
|
+
class ObservedCall
|
|
27
|
+
attr_reader :positional, :keyword
|
|
28
|
+
|
|
29
|
+
def initialize(positional: [], keyword: {})
|
|
30
|
+
@positional = positional.freeze
|
|
31
|
+
@keyword = keyword.freeze
|
|
32
|
+
freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def empty?
|
|
36
|
+
positional.empty? && keyword.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ==(other)
|
|
40
|
+
other.is_a?(ObservedCall) && positional == other.positional && keyword == other.keyword
|
|
41
|
+
end
|
|
42
|
+
alias eql? ==
|
|
43
|
+
|
|
44
|
+
def hash
|
|
45
|
+
[ObservedCall, positional, keyword].hash
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Lifts the legacy plain-Array shape into an
|
|
49
|
+
# `ObservedCall` carrier. Already-lifted values pass
|
|
50
|
+
# through unchanged. Used by `Generator#initialize`'s
|
|
51
|
+
# observations-normalisation pass so spec fixtures
|
|
52
|
+
# written against the slice-3 surface keep working.
|
|
53
|
+
def self.from(value)
|
|
54
|
+
case value
|
|
55
|
+
when ObservedCall then value
|
|
56
|
+
when Array then new(positional: value)
|
|
57
|
+
else raise ArgumentError, "expected Array or ObservedCall, got #{value.class}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|