rigortype 0.1.4 → 0.1.5
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 +40 -13
- data/lib/rigor/analysis/fact_store.rb +15 -3
- data/lib/rigor/analysis/result.rb +11 -3
- data/lib/rigor/analysis/run_stats.rb +193 -0
- data/lib/rigor/analysis/runner.rb +387 -12
- data/lib/rigor/analysis/worker_session.rb +327 -0
- data/lib/rigor/builtins/imported_refinements.rb +6 -2
- data/lib/rigor/builtins/regex_refinement.rb +17 -12
- data/lib/rigor/cache/rbs_descriptor.rb +3 -1
- data/lib/rigor/cache/store.rb +40 -7
- data/lib/rigor/cli.rb +52 -2
- data/lib/rigor/configuration.rb +131 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
- data/lib/rigor/environment/class_registry.rb +12 -3
- data/lib/rigor/environment/lockfile_resolver.rb +125 -0
- data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
- data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
- data/lib/rigor/environment/rbs_loader.rb +194 -6
- data/lib/rigor/environment/reflection.rb +152 -0
- data/lib/rigor/environment.rb +78 -6
- data/lib/rigor/inference/acceptance.rb +35 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
- data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
- data/lib/rigor/inference/expression_typer.rb +12 -2
- data/lib/rigor/inference/macro_block_self_type.rb +96 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
- data/lib/rigor/inference/method_dispatcher.rb +128 -3
- data/lib/rigor/inference/method_parameter_binder.rb +21 -11
- data/lib/rigor/inference/narrowing.rb +127 -8
- data/lib/rigor/inference/synthetic_method.rb +86 -0
- data/lib/rigor/inference/synthetic_method_index.rb +82 -0
- data/lib/rigor/inference/synthetic_method_scanner.rb +521 -0
- data/lib/rigor/plugin/blueprint.rb +60 -0
- data/lib/rigor/plugin/loader.rb +3 -1
- data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
- data/lib/rigor/plugin/macro/external_file.rb +143 -0
- data/lib/rigor/plugin/macro/heredoc_template.rb +201 -0
- data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
- data/lib/rigor/plugin/macro.rb +31 -0
- data/lib/rigor/plugin/manifest.rb +78 -7
- data/lib/rigor/plugin/registry.rb +32 -2
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/trinary.rb +15 -11
- data/lib/rigor/type/bot.rb +6 -3
- data/lib/rigor/type/combinator.rb +12 -1
- data/lib/rigor/type/integer_range.rb +7 -7
- data/lib/rigor/type/refined.rb +18 -12
- data/lib/rigor/type/top.rb +4 -3
- data/lib/rigor/type_node/generic.rb +7 -1
- data/lib/rigor/type_node/identifier.rb +9 -1
- data/lib/rigor/type_node/string_literal.rb +4 -1
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +5 -2
- data/sig/rigor/plugin/blueprint.rbs +7 -0
- data/sig/rigor/plugin/manifest.rbs +1 -1
- data/sig/rigor/plugin/registry.rbs +14 -1
- data/sig/rigor.rbs +35 -2
- metadata +39 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
module Macro
|
|
6
|
+
# ADR-16 Tier A declaration: "the block passed to a
|
|
7
|
+
# class-level DSL call of one of `verbs` runs as an instance
|
|
8
|
+
# method on `receiver_constraint`'s subclass tree, with
|
|
9
|
+
# `self` typed accordingly."
|
|
10
|
+
#
|
|
11
|
+
# Authored on a plugin manifest:
|
|
12
|
+
#
|
|
13
|
+
# manifest(
|
|
14
|
+
# id: "sinatra",
|
|
15
|
+
# version: "0.1.0",
|
|
16
|
+
# block_as_methods: [
|
|
17
|
+
# Rigor::Plugin::Macro::BlockAsMethod.new(
|
|
18
|
+
# receiver_constraint: "Sinatra::Base",
|
|
19
|
+
# verbs: %i[get post put delete head options patch link unlink]
|
|
20
|
+
# )
|
|
21
|
+
# ]
|
|
22
|
+
# )
|
|
23
|
+
#
|
|
24
|
+
# Sinatra is the canonical worked target (`Sinatra::Base#generate_method`
|
|
25
|
+
# at `lib/sinatra/base.rb:1788-1793` literally does
|
|
26
|
+
# `define_method(name, &block); remove_method` — the block IS
|
|
27
|
+
# the method body, byte-for-byte). The substrate adopts the
|
|
28
|
+
# same contract: declare the receiver constraint + the
|
|
29
|
+
# class-level methods whose block argument runs as if it were
|
|
30
|
+
# an instance method of the receiver.
|
|
31
|
+
#
|
|
32
|
+
# Slice 1a (this file) is **the contract only**. The engine
|
|
33
|
+
# hook that consults registered entries and narrows
|
|
34
|
+
# `Scope#self_type` for a block whose enclosing call matches
|
|
35
|
+
# arrives in slice 1b.
|
|
36
|
+
#
|
|
37
|
+
# ## Fields
|
|
38
|
+
#
|
|
39
|
+
# - `receiver_constraint` — fully-qualified class name (String)
|
|
40
|
+
# that the call's lexical receiver MUST be (or inherit from)
|
|
41
|
+
# for the entry to fire. For Sinatra modular-style this is
|
|
42
|
+
# `"Sinatra::Base"`; the substrate's class-context match
|
|
43
|
+
# accepts every subclass.
|
|
44
|
+
# - `verbs` — Array of Symbol method names. A call shape
|
|
45
|
+
# `<receiver_subclass>.get('/path') { ... }` matches when
|
|
46
|
+
# `:get` is in this list.
|
|
47
|
+
# - `self_type` — Symbol selecting the kind of `self`-binding
|
|
48
|
+
# the substrate applies inside the block. Slice 1a accepts
|
|
49
|
+
# only `:receiver_instance` (the block runs as an instance
|
|
50
|
+
# method of the receiver class). Other kinds (`:receiver_singleton`,
|
|
51
|
+
# `:dsl_recorder`) are reserved for later slices.
|
|
52
|
+
#
|
|
53
|
+
# ## Ractor-shareability
|
|
54
|
+
#
|
|
55
|
+
# All fields are frozen at construction (ADR-15 Phase 1).
|
|
56
|
+
# `verbs` is dup-frozen so the caller's mutable array does
|
|
57
|
+
# not leak into the value. `Ractor.shareable?` returns true
|
|
58
|
+
# after `#initialize`.
|
|
59
|
+
class BlockAsMethod
|
|
60
|
+
SELF_TYPE_RECEIVER_INSTANCE = :receiver_instance
|
|
61
|
+
VALID_SELF_TYPES = [SELF_TYPE_RECEIVER_INSTANCE].freeze
|
|
62
|
+
|
|
63
|
+
attr_reader :receiver_constraint, :verbs, :self_type
|
|
64
|
+
|
|
65
|
+
def initialize(receiver_constraint:, verbs:, self_type: SELF_TYPE_RECEIVER_INSTANCE)
|
|
66
|
+
validate_receiver_constraint!(receiver_constraint)
|
|
67
|
+
validate_verbs!(verbs)
|
|
68
|
+
validate_self_type!(self_type)
|
|
69
|
+
|
|
70
|
+
@receiver_constraint = receiver_constraint.dup.freeze
|
|
71
|
+
@verbs = verbs.map(&:to_sym).freeze
|
|
72
|
+
@self_type = self_type
|
|
73
|
+
freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def to_h
|
|
77
|
+
{
|
|
78
|
+
"receiver_constraint" => receiver_constraint,
|
|
79
|
+
"verbs" => verbs.map(&:to_s),
|
|
80
|
+
"self_type" => self_type.to_s
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def ==(other)
|
|
85
|
+
other.is_a?(BlockAsMethod) &&
|
|
86
|
+
receiver_constraint == other.receiver_constraint &&
|
|
87
|
+
verbs == other.verbs &&
|
|
88
|
+
self_type == other.self_type
|
|
89
|
+
end
|
|
90
|
+
alias eql? ==
|
|
91
|
+
|
|
92
|
+
def hash
|
|
93
|
+
[receiver_constraint, verbs, self_type].hash
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def validate_receiver_constraint!(value)
|
|
99
|
+
return if value.is_a?(String) && !value.empty?
|
|
100
|
+
|
|
101
|
+
raise ArgumentError,
|
|
102
|
+
"Plugin::Macro::BlockAsMethod#receiver_constraint must be a non-empty String, " \
|
|
103
|
+
"got #{value.inspect}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_verbs!(verbs)
|
|
107
|
+
unless verbs.is_a?(Array) && !verbs.empty?
|
|
108
|
+
raise ArgumentError,
|
|
109
|
+
"Plugin::Macro::BlockAsMethod#verbs must be a non-empty Array, got #{verbs.inspect}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
verbs.each do |v|
|
|
113
|
+
next if v.is_a?(Symbol) || (v.is_a?(String) && !v.empty?)
|
|
114
|
+
|
|
115
|
+
raise ArgumentError,
|
|
116
|
+
"Plugin::Macro::BlockAsMethod#verbs entries must be Symbol/non-empty String, " \
|
|
117
|
+
"got #{v.inspect}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def validate_self_type!(self_type)
|
|
122
|
+
return if VALID_SELF_TYPES.include?(self_type)
|
|
123
|
+
|
|
124
|
+
raise ArgumentError,
|
|
125
|
+
"Plugin::Macro::BlockAsMethod#self_type must be one of #{VALID_SELF_TYPES.inspect}, " \
|
|
126
|
+
"got #{self_type.inspect}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
module Macro
|
|
6
|
+
# ADR-16 Tier D declaration: "files matching `glob` are
|
|
7
|
+
# analysed as if their body were pasted at a call site whose
|
|
8
|
+
# `self` is an instance of `receiver_type` (and whose `@ivar`
|
|
9
|
+
# facts come from `bound_ivars`)."
|
|
10
|
+
#
|
|
11
|
+
# Worked motivating cases (per the per-library survey):
|
|
12
|
+
#
|
|
13
|
+
# - Redmine's `WebhookPayload#instance_eval(File.read(path), path, 1)`
|
|
14
|
+
# at `app/models/webhook_payload.rb:71`. The payload templates
|
|
15
|
+
# under `config/webhooks/*.rb` run with `self` typed as
|
|
16
|
+
# `Redmine::WebhookPayload` and ivars like `@event` / `@issue`
|
|
17
|
+
# / `@user` pre-bound by the caller.
|
|
18
|
+
# - tDiary Core's plugin loader pattern — `misc/plugin/*.rb`
|
|
19
|
+
# files loaded under `instance_eval` with the tDiary plugin
|
|
20
|
+
# instance as `self`.
|
|
21
|
+
#
|
|
22
|
+
# ## Authoring shape
|
|
23
|
+
#
|
|
24
|
+
# manifest(
|
|
25
|
+
# id: "redmine-webhook-payloads",
|
|
26
|
+
# version: "0.1.0",
|
|
27
|
+
# external_files: [
|
|
28
|
+
# Rigor::Plugin::Macro::ExternalFile.new(
|
|
29
|
+
# glob: "config/webhooks/*.rb",
|
|
30
|
+
# receiver_type: "Redmine::WebhookPayload",
|
|
31
|
+
# bound_ivars: {
|
|
32
|
+
# "@event" => "Symbol",
|
|
33
|
+
# "@issue" => "Issue?",
|
|
34
|
+
# "@user" => "User"
|
|
35
|
+
# }
|
|
36
|
+
# )
|
|
37
|
+
# ]
|
|
38
|
+
# )
|
|
39
|
+
#
|
|
40
|
+
# ## Fields
|
|
41
|
+
#
|
|
42
|
+
# - `glob` — non-empty String pattern. Interpreted relative
|
|
43
|
+
# to the project root (the directory containing `.rigor.yml`)
|
|
44
|
+
# at scan time. Slice 5a accepts any non-empty glob
|
|
45
|
+
# pattern syntactically; the engine integration (slice 5b)
|
|
46
|
+
# pins the resolution rule.
|
|
47
|
+
# - `receiver_type` — non-empty String. The class name `self`
|
|
48
|
+
# inside the loaded file binds to. Engine integration (slice
|
|
49
|
+
# 5b) narrows the file-entry scope's `self_type` to
|
|
50
|
+
# `Nominal[receiver_type]`.
|
|
51
|
+
# - `bound_ivars` — Hash<String, String>. Each key MUST start
|
|
52
|
+
# with `@`; each value is a non-empty type-name String. The
|
|
53
|
+
# engine pre-binds these as ivar facts in the file-entry
|
|
54
|
+
# scope (slice 5b).
|
|
55
|
+
#
|
|
56
|
+
# ## Slice 5a scope
|
|
57
|
+
#
|
|
58
|
+
# **This file ships the value class + manifest hook ONLY.**
|
|
59
|
+
# The engine integration that (a) adds matched files to the
|
|
60
|
+
# analysis set, (b) narrows the file-entry `self_type`, and
|
|
61
|
+
# (c) pre-binds `bound_ivars` as ivar facts is **queued for
|
|
62
|
+
# slice 5b**, gated on demonstrated demand. The survey
|
|
63
|
+
# identifies only Redmine + tDiary as concrete consumers;
|
|
64
|
+
# premature engine work is deferred until those cases (or
|
|
65
|
+
# equivalents) materialise as committed plugin targets.
|
|
66
|
+
#
|
|
67
|
+
# With only this slice landed, plugin authors CAN declare a
|
|
68
|
+
# Tier D manifest entry today — the declaration round-trips
|
|
69
|
+
# through `Manifest#to_h` (cache-key stable) and is exposed
|
|
70
|
+
# on `Manifest#external_files` — but the substrate does not
|
|
71
|
+
# yet act on it. The contract is forward-compatible: when
|
|
72
|
+
# slice 5b lands, the engine reads the same declarations and
|
|
73
|
+
# plugin gems do not need to change.
|
|
74
|
+
class ExternalFile
|
|
75
|
+
attr_reader :glob, :receiver_type, :bound_ivars
|
|
76
|
+
|
|
77
|
+
def initialize(glob:, receiver_type:, bound_ivars: {})
|
|
78
|
+
validate_glob!(glob)
|
|
79
|
+
validate_receiver_type!(receiver_type)
|
|
80
|
+
validate_bound_ivars!(bound_ivars)
|
|
81
|
+
|
|
82
|
+
@glob = glob.dup.freeze
|
|
83
|
+
@receiver_type = receiver_type.dup.freeze
|
|
84
|
+
@bound_ivars = bound_ivars.to_h { |k, v| [k.dup.freeze, v.dup.freeze] }.freeze
|
|
85
|
+
freeze
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def to_h
|
|
89
|
+
{
|
|
90
|
+
"glob" => glob,
|
|
91
|
+
"receiver_type" => receiver_type,
|
|
92
|
+
"bound_ivars" => bound_ivars
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def ==(other)
|
|
97
|
+
other.is_a?(ExternalFile) && to_h == other.to_h
|
|
98
|
+
end
|
|
99
|
+
alias eql? ==
|
|
100
|
+
|
|
101
|
+
def hash
|
|
102
|
+
to_h.hash
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def validate_glob!(value)
|
|
108
|
+
return if value.is_a?(String) && !value.empty?
|
|
109
|
+
|
|
110
|
+
raise ArgumentError,
|
|
111
|
+
"Plugin::Macro::ExternalFile#glob must be a non-empty String, got #{value.inspect}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def validate_receiver_type!(value)
|
|
115
|
+
return if value.is_a?(String) && !value.empty?
|
|
116
|
+
|
|
117
|
+
raise ArgumentError,
|
|
118
|
+
"Plugin::Macro::ExternalFile#receiver_type must be a non-empty String, got #{value.inspect}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def validate_bound_ivars!(value)
|
|
122
|
+
unless value.is_a?(Hash)
|
|
123
|
+
raise ArgumentError,
|
|
124
|
+
"Plugin::Macro::ExternalFile#bound_ivars must be a Hash, got #{value.inspect}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
value.each do |k, v|
|
|
128
|
+
unless k.is_a?(String) && k.start_with?("@") && k.length > 1
|
|
129
|
+
raise ArgumentError,
|
|
130
|
+
"Plugin::Macro::ExternalFile#bound_ivars key must be a String starting with `@`, " \
|
|
131
|
+
"got #{k.inspect}"
|
|
132
|
+
end
|
|
133
|
+
next if v.is_a?(String) && !v.empty?
|
|
134
|
+
|
|
135
|
+
raise ArgumentError,
|
|
136
|
+
"Plugin::Macro::ExternalFile#bound_ivars value must be a non-empty String, " \
|
|
137
|
+
"got #{v.inspect}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
module Macro
|
|
6
|
+
# ADR-16 Tier C declaration: "the class-level DSL call
|
|
7
|
+
# `<receiver_constraint>.<method_name>(name_arg, ...)` emits
|
|
8
|
+
# synthetic methods on the calling class, with names
|
|
9
|
+
# interpolating the source-visible literal argument at
|
|
10
|
+
# `symbol_arg_position`."
|
|
11
|
+
#
|
|
12
|
+
# Textbook target — dry-struct's `attribute :name, T` and
|
|
13
|
+
# ActiveStorage's `has_one_attached :avatar` both have this
|
|
14
|
+
# shape: a class-level call enumerates a literal Symbol
|
|
15
|
+
# argument; the framework `class_eval`s a heredoc
|
|
16
|
+
# interpolating that Symbol; the emit table is fixed.
|
|
17
|
+
#
|
|
18
|
+
# ## Authoring shape
|
|
19
|
+
#
|
|
20
|
+
# manifest(
|
|
21
|
+
# id: "activestorage",
|
|
22
|
+
# version: "0.1.0",
|
|
23
|
+
# heredoc_templates: [
|
|
24
|
+
# Rigor::Plugin::Macro::HeredocTemplate.new(
|
|
25
|
+
# receiver_constraint: "ActiveRecord::Base",
|
|
26
|
+
# method_name: :has_one_attached,
|
|
27
|
+
# symbol_arg_position: 0,
|
|
28
|
+
# emit: [
|
|
29
|
+
# { name: "#{name}", returns: "ActiveStorage::Attached::One" },
|
|
30
|
+
# { name: "#{name}_attachment", returns: "ActiveStorage::Attachment" },
|
|
31
|
+
# { name: "#{name}_blob", returns: "ActiveStorage::Blob" }
|
|
32
|
+
# ],
|
|
33
|
+
# class_level_emit: [
|
|
34
|
+
# { name: "with_attached_#{name}", returns: "ActiveRecord::Relation" }
|
|
35
|
+
# ]
|
|
36
|
+
# )
|
|
37
|
+
# ]
|
|
38
|
+
# )
|
|
39
|
+
#
|
|
40
|
+
# ## Fields
|
|
41
|
+
#
|
|
42
|
+
# - `receiver_constraint` — fully-qualified class name (String).
|
|
43
|
+
# Synthesis fires when the call's lexical receiver class
|
|
44
|
+
# equals or inherits from this constraint.
|
|
45
|
+
# - `method_name` — Symbol naming the DSL method (e.g.
|
|
46
|
+
# `:has_one_attached`, `:attribute`).
|
|
47
|
+
# - `symbol_arg_position` — Integer (default 0) — the
|
|
48
|
+
# argument index whose literal Symbol value becomes the
|
|
49
|
+
# `name` interpolated into each emit row's `name:`
|
|
50
|
+
# template. Slice 2a accepts non-negative integers only.
|
|
51
|
+
# - `emit` — Array of `Emit` (or coerced Hash) — instance
|
|
52
|
+
# methods to synthesise on the calling class.
|
|
53
|
+
# - `class_level_emit` — same shape, but the synthesised
|
|
54
|
+
# methods are singleton (class-level) methods.
|
|
55
|
+
#
|
|
56
|
+
# ## Floor / ceiling per ADR-16 WD13
|
|
57
|
+
#
|
|
58
|
+
# Slice 2 ships at the **floor**: each emit row's `name:`
|
|
59
|
+
# is the source of truth for the synthetic method's name
|
|
60
|
+
# (a single `"\#{name}"` placeholder gets interpolated with
|
|
61
|
+
# the literal symbol argument at `symbol_arg_position`).
|
|
62
|
+
# The `returns:` strings are **recorded in the manifest but
|
|
63
|
+
# not resolved**; the engine emits synthetic methods with
|
|
64
|
+
# `Dynamic[T]` returns plus a
|
|
65
|
+
# `macro.tier_c.unresolved-return` provenance marker.
|
|
66
|
+
# Precise return-type resolution via ADR-13's
|
|
67
|
+
# `Plugin::TypeNodeResolver` is the **ceiling**, deferred
|
|
68
|
+
# to a later slice — the `returns:` declarations cost
|
|
69
|
+
# nothing to write today and unlock precision then.
|
|
70
|
+
#
|
|
71
|
+
# ## Slice 2a scope
|
|
72
|
+
#
|
|
73
|
+
# This file ships the value class only. Slice 2b wires the
|
|
74
|
+
# pre-pass that scans Tier C call sites + the
|
|
75
|
+
# `SyntheticMethodIndex` the dispatcher consults; slice 2c
|
|
76
|
+
# authors `examples/rigor-dry-struct/` and
|
|
77
|
+
# `examples/rigor-dry-types/` as the worked consumers.
|
|
78
|
+
class HeredocTemplate
|
|
79
|
+
NAME_PLACEHOLDER = "\#{name}"
|
|
80
|
+
|
|
81
|
+
attr_reader :receiver_constraint, :method_name, :symbol_arg_position, :emit, :class_level_emit
|
|
82
|
+
|
|
83
|
+
def initialize(receiver_constraint:, method_name:, symbol_arg_position: 0, emit: [], class_level_emit: [])
|
|
84
|
+
validate_receiver_constraint!(receiver_constraint)
|
|
85
|
+
validate_method_name!(method_name)
|
|
86
|
+
validate_symbol_arg_position!(symbol_arg_position)
|
|
87
|
+
|
|
88
|
+
@receiver_constraint = receiver_constraint.dup.freeze
|
|
89
|
+
@method_name = method_name.to_sym
|
|
90
|
+
@symbol_arg_position = symbol_arg_position
|
|
91
|
+
@emit = coerce_emit_list!(emit, "emit")
|
|
92
|
+
@class_level_emit = coerce_emit_list!(class_level_emit, "class_level_emit")
|
|
93
|
+
freeze
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def to_h
|
|
97
|
+
{
|
|
98
|
+
"receiver_constraint" => receiver_constraint,
|
|
99
|
+
"method_name" => method_name.to_s,
|
|
100
|
+
"symbol_arg_position" => symbol_arg_position,
|
|
101
|
+
"emit" => emit.map(&:to_h),
|
|
102
|
+
"class_level_emit" => class_level_emit.map(&:to_h)
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def ==(other)
|
|
107
|
+
other.is_a?(HeredocTemplate) && to_h == other.to_h
|
|
108
|
+
end
|
|
109
|
+
alias eql? ==
|
|
110
|
+
|
|
111
|
+
def hash
|
|
112
|
+
to_h.hash
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# One row of an emit table: the synthetic method's
|
|
116
|
+
# name-template (the analyzer interpolates `\#{name}` with
|
|
117
|
+
# the call-site literal symbol) and its declared return
|
|
118
|
+
# type (recorded as a string in slice 2a, resolved by the
|
|
119
|
+
# ceiling slice via ADR-13).
|
|
120
|
+
class Emit
|
|
121
|
+
attr_reader :name, :returns
|
|
122
|
+
|
|
123
|
+
def initialize(name:, returns:)
|
|
124
|
+
unless name.is_a?(String) && !name.empty?
|
|
125
|
+
raise ArgumentError,
|
|
126
|
+
"Macro::HeredocTemplate::Emit#name must be a non-empty String, got #{name.inspect}"
|
|
127
|
+
end
|
|
128
|
+
unless returns.is_a?(String) && !returns.empty?
|
|
129
|
+
raise ArgumentError,
|
|
130
|
+
"Macro::HeredocTemplate::Emit#returns must be a non-empty String, got #{returns.inspect}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
@name = name.dup.freeze
|
|
134
|
+
@returns = returns.dup.freeze
|
|
135
|
+
freeze
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def to_h
|
|
139
|
+
{ "name" => name, "returns" => returns }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def ==(other)
|
|
143
|
+
other.is_a?(Emit) && name == other.name && returns == other.returns
|
|
144
|
+
end
|
|
145
|
+
alias eql? ==
|
|
146
|
+
|
|
147
|
+
def hash
|
|
148
|
+
[name, returns].hash
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def validate_receiver_constraint!(value)
|
|
155
|
+
return if value.is_a?(String) && !value.empty?
|
|
156
|
+
|
|
157
|
+
raise ArgumentError,
|
|
158
|
+
"Plugin::Macro::HeredocTemplate#receiver_constraint must be a non-empty String, " \
|
|
159
|
+
"got #{value.inspect}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def validate_method_name!(value)
|
|
163
|
+
return if value.is_a?(Symbol) || (value.is_a?(String) && !value.empty?)
|
|
164
|
+
|
|
165
|
+
raise ArgumentError,
|
|
166
|
+
"Plugin::Macro::HeredocTemplate#method_name must be Symbol or non-empty String, " \
|
|
167
|
+
"got #{value.inspect}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def validate_symbol_arg_position!(value)
|
|
171
|
+
return if value.is_a?(Integer) && value >= 0
|
|
172
|
+
|
|
173
|
+
raise ArgumentError,
|
|
174
|
+
"Plugin::Macro::HeredocTemplate#symbol_arg_position must be a non-negative Integer, " \
|
|
175
|
+
"got #{value.inspect}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def coerce_emit_list!(entries, label)
|
|
179
|
+
unless entries.is_a?(Array)
|
|
180
|
+
raise ArgumentError,
|
|
181
|
+
"Plugin::Macro::HeredocTemplate##{label} must be an Array, got #{entries.inspect}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
entries.map { |entry| coerce_emit_entry!(entry, label) }.freeze
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def coerce_emit_entry!(entry, label)
|
|
188
|
+
case entry
|
|
189
|
+
when Emit then entry
|
|
190
|
+
when Hash
|
|
191
|
+
Emit.new(name: entry[:name] || entry["name"], returns: entry[:returns] || entry["returns"])
|
|
192
|
+
else
|
|
193
|
+
raise ArgumentError,
|
|
194
|
+
"Plugin::Macro::HeredocTemplate##{label} entry must be an Emit or Hash, " \
|
|
195
|
+
"got #{entry.inspect}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
module Macro
|
|
6
|
+
# ADR-16 Tier B declaration: "the class-level DSL call
|
|
7
|
+
# `<receiver_constraint>.<method_name>(:trait_a, :trait_b, ...)`
|
|
8
|
+
# effectively includes the modules named in
|
|
9
|
+
# `modules_by_symbol[:trait_a]` + `[:trait_b]` (plus any
|
|
10
|
+
# `always_included` modules) on the calling class."
|
|
11
|
+
#
|
|
12
|
+
# Worked target: Devise's model-side `devise :database_authenticatable,
|
|
13
|
+
# :recoverable` DSL (per-library survey § Devise). The bundled
|
|
14
|
+
# registry mirrors `lib/devise/modules.rb`'s symbol → module table;
|
|
15
|
+
# `always_included` carries the modules Devise always mixes in
|
|
16
|
+
# regardless of selection (e.g. `Devise::Models::Authenticatable`).
|
|
17
|
+
#
|
|
18
|
+
# ## Authoring shape
|
|
19
|
+
#
|
|
20
|
+
# manifest(
|
|
21
|
+
# id: "devise",
|
|
22
|
+
# version: "0.1.0",
|
|
23
|
+
# trait_registries: [
|
|
24
|
+
# Rigor::Plugin::Macro::TraitRegistry.new(
|
|
25
|
+
# receiver_constraint: "ActiveRecord::Base",
|
|
26
|
+
# method_name: :devise,
|
|
27
|
+
# symbol_arg_position: :rest,
|
|
28
|
+
# modules_by_symbol: {
|
|
29
|
+
# database_authenticatable: "Devise::Models::DatabaseAuthenticatable",
|
|
30
|
+
# recoverable: "Devise::Models::Recoverable",
|
|
31
|
+
# rememberable: "Devise::Models::Rememberable"
|
|
32
|
+
# },
|
|
33
|
+
# always_included: ["Devise::Models::Authenticatable"]
|
|
34
|
+
# )
|
|
35
|
+
# ]
|
|
36
|
+
# )
|
|
37
|
+
#
|
|
38
|
+
# ## Fields
|
|
39
|
+
#
|
|
40
|
+
# - `receiver_constraint` — fully-qualified class name (String).
|
|
41
|
+
# Synthesis fires when the call's lexical receiver class
|
|
42
|
+
# equals or inherits from this constraint.
|
|
43
|
+
# - `method_name` — Symbol naming the DSL method
|
|
44
|
+
# (e.g. `:devise`).
|
|
45
|
+
# - `symbol_arg_position` — `:rest` (all positional Symbol args
|
|
46
|
+
# are traits, slice 3a's only supported form) or a
|
|
47
|
+
# non-negative Integer (the index of a single trait symbol —
|
|
48
|
+
# reserved for future shapes; not yet honoured by the
|
|
49
|
+
# scanner).
|
|
50
|
+
# - `modules_by_symbol` — Hash<Symbol, String>. Maps each
|
|
51
|
+
# recognised trait symbol to a fully-qualified module name.
|
|
52
|
+
# Symbols not in the table fall through (silent skip; the
|
|
53
|
+
# scanner emits a `macro.tier_b.unknown-trait` `:info`
|
|
54
|
+
# provenance marker per WD9 / WD13).
|
|
55
|
+
# - `always_included` — Array<String>. Fully-qualified module
|
|
56
|
+
# names that are added to every call site (even when no
|
|
57
|
+
# symbols match). Mirrors Devise's `always_include` modules.
|
|
58
|
+
#
|
|
59
|
+
# ## Floor / ceiling per ADR-16 WD13
|
|
60
|
+
#
|
|
61
|
+
# Slice 3 ships at the **floor**: the substrate per-method-
|
|
62
|
+
# explodes each included module's RBS instance methods into
|
|
63
|
+
# the existing `SyntheticMethodIndex` (slice 2b primitive).
|
|
64
|
+
# The synthesised methods adopt the module's authored RBS
|
|
65
|
+
# return types — Tier B is NOT subject to the Tier C
|
|
66
|
+
# `Dynamic[T]` floor because the source-of-truth (the
|
|
67
|
+
# module's authored RBS) is not a manifest-declared string.
|
|
68
|
+
# Per ADR-5 robustness, the substrate does not fabricate
|
|
69
|
+
# precision; it simply replays the modules's signatures.
|
|
70
|
+
#
|
|
71
|
+
# **Out of scope for slice 3** (deferred follow-ups):
|
|
72
|
+
# - `class_methods_module:` per-trait (Devise's `ClassMethods`
|
|
73
|
+
# extend-pattern); slice 3 covers instance methods only.
|
|
74
|
+
# - `sort_key:` for controlled include ordering across traits;
|
|
75
|
+
# slice 3 uses plugin-registration order then registry
|
|
76
|
+
# declaration order.
|
|
77
|
+
# - `included_do_digest:` — the per-module `included do` block
|
|
78
|
+
# facts (attr_reader / after_save / etc.); slice 3 emits
|
|
79
|
+
# only the module's plain instance methods.
|
|
80
|
+
#
|
|
81
|
+
# ## Slice 3a scope
|
|
82
|
+
#
|
|
83
|
+
# This file ships the value class only. Slice 3b wires the
|
|
84
|
+
# scanner that walks Tier B call sites + the per-method
|
|
85
|
+
# explosion via `SyntheticMethodIndex`; slice 3c authors
|
|
86
|
+
# `examples/rigor-devise/` model side as the worked consumer.
|
|
87
|
+
class TraitRegistry
|
|
88
|
+
REST_POSITION = :rest
|
|
89
|
+
|
|
90
|
+
attr_reader :receiver_constraint, :method_name, :symbol_arg_position, :modules_by_symbol, :always_included
|
|
91
|
+
|
|
92
|
+
def initialize(receiver_constraint:, method_name:, symbol_arg_position: REST_POSITION,
|
|
93
|
+
modules_by_symbol: {}, always_included: [])
|
|
94
|
+
validate_receiver_constraint!(receiver_constraint)
|
|
95
|
+
validate_method_name!(method_name)
|
|
96
|
+
validate_symbol_arg_position!(symbol_arg_position)
|
|
97
|
+
validate_modules_by_symbol!(modules_by_symbol)
|
|
98
|
+
validate_always_included!(always_included)
|
|
99
|
+
|
|
100
|
+
@receiver_constraint = receiver_constraint.dup.freeze
|
|
101
|
+
@method_name = method_name.to_sym
|
|
102
|
+
@symbol_arg_position = symbol_arg_position
|
|
103
|
+
@modules_by_symbol = modules_by_symbol.to_h { |k, v| [k.to_sym, v.dup.freeze] }.freeze
|
|
104
|
+
@always_included = always_included.map { |m| m.dup.freeze }.freeze
|
|
105
|
+
freeze
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def to_h
|
|
109
|
+
{
|
|
110
|
+
"receiver_constraint" => receiver_constraint,
|
|
111
|
+
"method_name" => method_name.to_s,
|
|
112
|
+
"symbol_arg_position" => symbol_arg_position.to_s,
|
|
113
|
+
"modules_by_symbol" => modules_by_symbol.to_h { |k, v| [k.to_s, v] },
|
|
114
|
+
"always_included" => always_included
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ==(other)
|
|
119
|
+
other.is_a?(TraitRegistry) && to_h == other.to_h
|
|
120
|
+
end
|
|
121
|
+
alias eql? ==
|
|
122
|
+
|
|
123
|
+
def hash
|
|
124
|
+
to_h.hash
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [String, nil] fully-qualified module name for the
|
|
128
|
+
# given trait symbol, or nil when the registry doesn't
|
|
129
|
+
# know the symbol (caller emits a tier_b.unknown-trait
|
|
130
|
+
# provenance marker and falls through).
|
|
131
|
+
def module_for(symbol)
|
|
132
|
+
modules_by_symbol[symbol.to_sym]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def validate_receiver_constraint!(value)
|
|
138
|
+
return if value.is_a?(String) && !value.empty?
|
|
139
|
+
|
|
140
|
+
raise ArgumentError,
|
|
141
|
+
"Plugin::Macro::TraitRegistry#receiver_constraint must be a non-empty String, " \
|
|
142
|
+
"got #{value.inspect}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def validate_method_name!(value)
|
|
146
|
+
return if value.is_a?(Symbol) || (value.is_a?(String) && !value.empty?)
|
|
147
|
+
|
|
148
|
+
raise ArgumentError,
|
|
149
|
+
"Plugin::Macro::TraitRegistry#method_name must be Symbol or non-empty String, " \
|
|
150
|
+
"got #{value.inspect}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def validate_symbol_arg_position!(value)
|
|
154
|
+
return if value == REST_POSITION || (value.is_a?(Integer) && value >= 0)
|
|
155
|
+
|
|
156
|
+
raise ArgumentError,
|
|
157
|
+
"Plugin::Macro::TraitRegistry#symbol_arg_position must be :rest or a non-negative Integer, " \
|
|
158
|
+
"got #{value.inspect}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def validate_modules_by_symbol!(value)
|
|
162
|
+
unless value.is_a?(Hash)
|
|
163
|
+
raise ArgumentError,
|
|
164
|
+
"Plugin::Macro::TraitRegistry#modules_by_symbol must be a Hash, got #{value.inspect}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
value.each do |k, v|
|
|
168
|
+
unless k.is_a?(Symbol) || (k.is_a?(String) && !k.empty?)
|
|
169
|
+
raise ArgumentError,
|
|
170
|
+
"Plugin::Macro::TraitRegistry#modules_by_symbol key must be Symbol/non-empty String, " \
|
|
171
|
+
"got #{k.inspect}"
|
|
172
|
+
end
|
|
173
|
+
next if v.is_a?(String) && !v.empty?
|
|
174
|
+
|
|
175
|
+
raise ArgumentError,
|
|
176
|
+
"Plugin::Macro::TraitRegistry#modules_by_symbol value must be a non-empty String, " \
|
|
177
|
+
"got #{v.inspect}"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def validate_always_included!(value)
|
|
182
|
+
unless value.is_a?(Array)
|
|
183
|
+
raise ArgumentError,
|
|
184
|
+
"Plugin::Macro::TraitRegistry#always_included must be an Array, got #{value.inspect}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
value.each do |m|
|
|
188
|
+
next if m.is_a?(String) && !m.empty?
|
|
189
|
+
|
|
190
|
+
raise ArgumentError,
|
|
191
|
+
"Plugin::Macro::TraitRegistry#always_included entry must be a non-empty String, " \
|
|
192
|
+
"got #{m.inspect}"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|