rigortype 0.1.4 → 0.1.6
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 +69 -56
- data/lib/rigor/analysis/buffer_binding.rb +36 -0
- data/lib/rigor/analysis/check_rules.rb +11 -1
- data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
- data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
- data/lib/rigor/analysis/fact_store.rb +15 -3
- data/lib/rigor/analysis/project_scan.rb +39 -0
- data/lib/rigor/analysis/result.rb +11 -3
- data/lib/rigor/analysis/run_stats.rb +193 -0
- data/lib/rigor/analysis/runner.rb +681 -19
- data/lib/rigor/analysis/worker_session.rb +339 -0
- data/lib/rigor/builtins/hkt_builtins.rb +342 -0
- data/lib/rigor/builtins/imported_refinements.rb +6 -2
- data/lib/rigor/builtins/regex_refinement.rb +17 -12
- data/lib/rigor/builtins/static_return_refinements.rb +120 -0
- data/lib/rigor/cache/rbs_descriptor.rb +3 -1
- data/lib/rigor/cache/store.rb +72 -9
- data/lib/rigor/cli/lsp_command.rb +129 -0
- data/lib/rigor/cli/type_of_command.rb +44 -5
- data/lib/rigor/cli.rb +122 -10
- data/lib/rigor/configuration.rb +168 -7
- data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
- data/lib/rigor/environment/class_registry.rb +12 -3
- data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
- 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 +238 -7
- data/lib/rigor/environment/reflection.rb +152 -0
- data/lib/rigor/environment/reporters.rb +40 -0
- data/lib/rigor/environment.rb +179 -10
- data/lib/rigor/inference/acceptance.rb +83 -4
- 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 +59 -2
- data/lib/rigor/inference/hkt_body.rb +171 -0
- data/lib/rigor/inference/hkt_body_parser.rb +363 -0
- data/lib/rigor/inference/hkt_reducer.rb +256 -0
- data/lib/rigor/inference/hkt_registry.rb +223 -0
- 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 +126 -31
- data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
- data/lib/rigor/inference/method_dispatcher.rb +282 -6
- data/lib/rigor/inference/method_parameter_binder.rb +21 -11
- data/lib/rigor/inference/narrowing.rb +127 -8
- data/lib/rigor/inference/project_patched_methods.rb +70 -0
- data/lib/rigor/inference/project_patched_scanner.rb +210 -0
- data/lib/rigor/inference/scope_indexer.rb +156 -12
- data/lib/rigor/inference/statement_evaluator.rb +106 -6
- 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 +599 -0
- data/lib/rigor/language_server/buffer_table.rb +63 -0
- data/lib/rigor/language_server/completion_provider.rb +438 -0
- data/lib/rigor/language_server/debouncer.rb +86 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
- data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
- data/lib/rigor/language_server/folding_range_provider.rb +75 -0
- data/lib/rigor/language_server/hover_provider.rb +74 -0
- data/lib/rigor/language_server/hover_renderer.rb +312 -0
- data/lib/rigor/language_server/loop.rb +71 -0
- data/lib/rigor/language_server/project_context.rb +145 -0
- data/lib/rigor/language_server/selection_range_provider.rb +93 -0
- data/lib/rigor/language_server/server.rb +384 -0
- data/lib/rigor/language_server/signature_help_provider.rb +249 -0
- data/lib/rigor/language_server/synchronized_writer.rb +28 -0
- data/lib/rigor/language_server/uri.rb +40 -0
- data/lib/rigor/language_server.rb +29 -0
- data/lib/rigor/plugin/base.rb +63 -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 +315 -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 +127 -9
- data/lib/rigor/plugin/registry.rb +51 -2
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
- data/lib/rigor/rbs_extended.rb +82 -2
- data/lib/rigor/sig_gen/generator.rb +12 -3
- data/lib/rigor/trinary.rb +15 -11
- data/lib/rigor/type/app.rb +107 -0
- 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.rb +1 -0
- 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 +11 -4
- data/sig/rigor/inference.rbs +2 -0
- 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 +37 -2
- metadata +92 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module LanguageServer
|
|
5
|
+
# LSP DocumentUri ↔ filesystem path conversions. v1 supports
|
|
6
|
+
# only `file://` URIs; other schemes (e.g. `untitled:`) return
|
|
7
|
+
# nil from `#to_path` so the caller can short-circuit.
|
|
8
|
+
#
|
|
9
|
+
# Windows drive-letter handling: `file:///C:/path` → `C:/path`.
|
|
10
|
+
# The leading slash after the scheme is dropped on Windows; on
|
|
11
|
+
# POSIX it stays. v1 ships POSIX behaviour; Windows specifics
|
|
12
|
+
# land when Windows CI is wired (see design doc § "Open
|
|
13
|
+
# questions").
|
|
14
|
+
module Uri
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
FILE_SCHEME = "file://"
|
|
18
|
+
private_constant :FILE_SCHEME
|
|
19
|
+
|
|
20
|
+
# @return [String, nil] absolute filesystem path for a
|
|
21
|
+
# `file://` URI, or nil for unsupported schemes.
|
|
22
|
+
def to_path(uri)
|
|
23
|
+
return nil unless uri.is_a?(String) && uri.start_with?(FILE_SCHEME)
|
|
24
|
+
|
|
25
|
+
# Percent-decode at the BYTE level so multi-byte UTF-8
|
|
26
|
+
# escapes (`%E6%97%A5` → `日`) reassemble correctly. Each
|
|
27
|
+
# `%xx` decodes to one raw byte; the result is a byte string
|
|
28
|
+
# we re-interpret as UTF-8. `delete_prefix` always returns
|
|
29
|
+
# a String (vs `byteslice` whose RBS return is `String?`).
|
|
30
|
+
uri.delete_prefix(FILE_SCHEME).b
|
|
31
|
+
.gsub(/%([0-9A-Fa-f]{2})/) { ::Regexp.last_match(1).hex.chr }
|
|
32
|
+
.force_encoding(Encoding::UTF_8)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def from_path(path)
|
|
36
|
+
"#{FILE_SCHEME}#{path}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
# The Language Server subsystem. See
|
|
5
|
+
# `docs/design/20260517-language-server.md` for the design.
|
|
6
|
+
# Slice 1 ships the namespace + a minimal {Server} lifecycle the
|
|
7
|
+
# `rigor lsp` CLI subcommand can drive. Later slices add the
|
|
8
|
+
# stdio JSON-RPC transport (slice 2), the BufferTable (slice 3),
|
|
9
|
+
# `publishDiagnostics` (slice 4), and the rest of the v1 capability
|
|
10
|
+
# surface.
|
|
11
|
+
module LanguageServer
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require_relative "language_server/buffer_table"
|
|
16
|
+
require_relative "language_server/uri"
|
|
17
|
+
require_relative "language_server/project_context"
|
|
18
|
+
require_relative "language_server/debouncer"
|
|
19
|
+
require_relative "language_server/synchronized_writer"
|
|
20
|
+
require_relative "language_server/diagnostic_publisher"
|
|
21
|
+
require_relative "language_server/hover_renderer"
|
|
22
|
+
require_relative "language_server/hover_provider"
|
|
23
|
+
require_relative "language_server/completion_provider"
|
|
24
|
+
require_relative "language_server/signature_help_provider"
|
|
25
|
+
require_relative "language_server/document_symbol_provider"
|
|
26
|
+
require_relative "language_server/folding_range_provider"
|
|
27
|
+
require_relative "language_server/selection_range_provider"
|
|
28
|
+
require_relative "language_server/server"
|
|
29
|
+
require_relative "language_server/loop"
|
data/lib/rigor/plugin/base.rb
CHANGED
|
@@ -243,8 +243,71 @@ module Rigor
|
|
|
243
243
|
end
|
|
244
244
|
end
|
|
245
245
|
|
|
246
|
+
# Builds a `Cache::Descriptor` covering every file matched by
|
|
247
|
+
# `pattern` (a glob, e.g. `"**/*.rb"`) under any of `roots`.
|
|
248
|
+
# Each matching file contributes a `:digest`-comparator
|
|
249
|
+
# `FileEntry` so the cache invalidates on any content change,
|
|
250
|
+
# any addition (a newly-glob-matched file appears in the
|
|
251
|
+
# descriptor), or any removal (the previously-matched file
|
|
252
|
+
# drops out).
|
|
253
|
+
#
|
|
254
|
+
# Pass the returned descriptor as `cache_for(..., descriptor: …)`
|
|
255
|
+
# so the cache key reflects the project files the producer
|
|
256
|
+
# reads from. Without it, `Plugin::Base#cache_for`'s
|
|
257
|
+
# auto-built descriptor only includes files the
|
|
258
|
+
# {Plugin::IoBoundary} has already read in the current
|
|
259
|
+
# process — empty on the first call of a fresh process — so
|
|
260
|
+
# the cache key is identical regardless of project state and
|
|
261
|
+
# warm runs return stale producer output when files have
|
|
262
|
+
# changed between sessions.
|
|
263
|
+
#
|
|
264
|
+
# Discovery-style producers (`actioncable`'s `:channel_index`,
|
|
265
|
+
# `actionmailer`'s `:mailer_index`, `rails-i18n`'s
|
|
266
|
+
# `:locale_index`) all follow the same pattern: walk a glob
|
|
267
|
+
# under one or more search roots, parse / read every match,
|
|
268
|
+
# build a typed index. They MUST call this helper at the
|
|
269
|
+
# `cache_for(descriptor: …)` site to be cache-correct under
|
|
270
|
+
# the persistent `Cache::Store` `rigor check` uses by
|
|
271
|
+
# default.
|
|
272
|
+
#
|
|
273
|
+
# The helper pays one SHA-256 read per matched file at
|
|
274
|
+
# call time; the producer block typically re-reads through
|
|
275
|
+
# `io_boundary.read_file` so the cost is doubled. For
|
|
276
|
+
# discovery globs in the 10-100 file range this is
|
|
277
|
+
# negligible (~ms) relative to the parse + walk the
|
|
278
|
+
# producer does on cache miss.
|
|
279
|
+
#
|
|
280
|
+
# @param roots [Array<String>] search roots (relative to
|
|
281
|
+
# the project root, or absolute paths)
|
|
282
|
+
# @param patterns [Array<String>] glob suffixes joined under
|
|
283
|
+
# each root via `File.join(root, pattern)`. Multiple
|
|
284
|
+
# patterns union into one descriptor (`"**/*.erb",
|
|
285
|
+
# "**/*.html"` etc.).
|
|
286
|
+
# @return [Rigor::Cache::Descriptor]
|
|
287
|
+
def glob_descriptor(roots, *patterns)
|
|
288
|
+
files = collect_glob_files(Array(roots), patterns)
|
|
289
|
+
entries = files.map do |path|
|
|
290
|
+
Cache::Descriptor::FileEntry.new(
|
|
291
|
+
path: path,
|
|
292
|
+
comparator: :digest,
|
|
293
|
+
value: Digest::SHA256.file(path).hexdigest
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
Cache::Descriptor.new(files: entries)
|
|
297
|
+
end
|
|
298
|
+
|
|
246
299
|
private
|
|
247
300
|
|
|
301
|
+
def collect_glob_files(roots, patterns)
|
|
302
|
+
matched = roots.flat_map do |root|
|
|
303
|
+
absolute = File.expand_path(root.to_s)
|
|
304
|
+
next [] unless File.directory?(absolute)
|
|
305
|
+
|
|
306
|
+
patterns.flat_map { |pattern| Dir.glob(File.join(absolute, pattern.to_s)) }
|
|
307
|
+
end
|
|
308
|
+
matched.uniq.sort.select { |path| File.file?(path) }
|
|
309
|
+
end
|
|
310
|
+
|
|
248
311
|
# ADR-7 § "Slice 6-B" — composes the per-call cache
|
|
249
312
|
# descriptor from (1) the plugin's PluginEntry template
|
|
250
313
|
# and (2) the IoBoundary's accumulated FileEntry rows.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
# Frozen, `Ractor.shareable?` description of how to materialise
|
|
6
|
+
# a single plugin instance inside a worker.
|
|
7
|
+
# [ADR-15](../../../docs/adr/15-ractor-concurrency.md) Phase 3
|
|
8
|
+
# introduces the carrier so the eventual worker pool can pass
|
|
9
|
+
# `Array<Blueprint>` across a Ractor boundary verbatim; each
|
|
10
|
+
# worker calls {#materialize} once at startup, then owns its
|
|
11
|
+
# plugin instances (and their mutable per-run accumulators)
|
|
12
|
+
# for the lifetime of the worker.
|
|
13
|
+
#
|
|
14
|
+
# Holds the constant path (`String`) of the plugin class — NOT
|
|
15
|
+
# the class object itself. Plugin gems are required from the
|
|
16
|
+
# main Ractor BEFORE any worker spawns, so every Ractor
|
|
17
|
+
# resolves the same constant via `Object.const_get`.
|
|
18
|
+
#
|
|
19
|
+
# The `config` Hash is deep-copied + made shareable at
|
|
20
|
+
# construction so the Blueprint stays decoupled from whatever
|
|
21
|
+
# Hash the project configuration emitted. The original config
|
|
22
|
+
# Hash held by the loader is therefore unaffected by Blueprint
|
|
23
|
+
# construction.
|
|
24
|
+
class Blueprint
|
|
25
|
+
attr_reader :klass_name, :config
|
|
26
|
+
|
|
27
|
+
def initialize(klass_name:, config: {})
|
|
28
|
+
@klass_name = normalise_klass_name(klass_name)
|
|
29
|
+
@config = Ractor.make_shareable(Marshal.load(Marshal.dump(config)))
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Resolves the plugin class via `Object.const_get`, builds a
|
|
34
|
+
# fresh instance bound to the supplied services container,
|
|
35
|
+
# and calls `#init(services)`. Mirrors
|
|
36
|
+
# {Rigor::Plugin::Loader#instantiate} bit-for-bit so the
|
|
37
|
+
# blueprint-driven path stays consistent with the
|
|
38
|
+
# configuration-driven load path.
|
|
39
|
+
def materialize(services:)
|
|
40
|
+
klass = Object.const_get(@klass_name)
|
|
41
|
+
plugin = klass.new(services: services, config: @config)
|
|
42
|
+
plugin.init(services)
|
|
43
|
+
plugin
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def normalise_klass_name(name)
|
|
49
|
+
case name
|
|
50
|
+
when String
|
|
51
|
+
name.dup.freeze
|
|
52
|
+
when Module
|
|
53
|
+
name.name.dup.freeze
|
|
54
|
+
else
|
|
55
|
+
raise ArgumentError, "Blueprint klass_name must be a String or Module, got #{name.class}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/rigor/plugin/loader.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "blueprint"
|
|
3
4
|
require_relative "registry"
|
|
4
5
|
require_relative "load_error"
|
|
5
6
|
|
|
@@ -72,7 +73,8 @@ module Rigor
|
|
|
72
73
|
plugins, sort_errors = topo_sort_plugins(plugins)
|
|
73
74
|
load_errors.concat(sort_errors)
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
blueprints = plugins.map { |plugin| Blueprint.new(klass_name: plugin.class.name, config: plugin.config) }
|
|
77
|
+
Registry.new(plugins: plugins, blueprints: blueprints, load_errors: load_errors)
|
|
76
78
|
end
|
|
77
79
|
|
|
78
80
|
private
|
|
@@ -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
|