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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +69 -56
  3. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  4. data/lib/rigor/analysis/check_rules.rb +11 -1
  5. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  6. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  8. data/lib/rigor/analysis/fact_store.rb +15 -3
  9. data/lib/rigor/analysis/project_scan.rb +39 -0
  10. data/lib/rigor/analysis/result.rb +11 -3
  11. data/lib/rigor/analysis/run_stats.rb +193 -0
  12. data/lib/rigor/analysis/runner.rb +681 -19
  13. data/lib/rigor/analysis/worker_session.rb +339 -0
  14. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  15. data/lib/rigor/builtins/imported_refinements.rb +6 -2
  16. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  17. data/lib/rigor/builtins/static_return_refinements.rb +120 -0
  18. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  19. data/lib/rigor/cache/store.rb +72 -9
  20. data/lib/rigor/cli/lsp_command.rb +129 -0
  21. data/lib/rigor/cli/type_of_command.rb +44 -5
  22. data/lib/rigor/cli.rb +122 -10
  23. data/lib/rigor/configuration.rb +168 -7
  24. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  25. data/lib/rigor/environment/class_registry.rb +12 -3
  26. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  27. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  28. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  29. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  30. data/lib/rigor/environment/rbs_loader.rb +238 -7
  31. data/lib/rigor/environment/reflection.rb +152 -0
  32. data/lib/rigor/environment/reporters.rb +40 -0
  33. data/lib/rigor/environment.rb +179 -10
  34. data/lib/rigor/inference/acceptance.rb +83 -4
  35. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  36. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  37. data/lib/rigor/inference/expression_typer.rb +59 -2
  38. data/lib/rigor/inference/hkt_body.rb +171 -0
  39. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  40. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  41. data/lib/rigor/inference/hkt_registry.rb +223 -0
  42. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  43. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
  44. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  45. data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
  46. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +126 -31
  47. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  48. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
  49. data/lib/rigor/inference/method_dispatcher.rb +282 -6
  50. data/lib/rigor/inference/method_parameter_binder.rb +21 -11
  51. data/lib/rigor/inference/narrowing.rb +127 -8
  52. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  53. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  54. data/lib/rigor/inference/scope_indexer.rb +156 -12
  55. data/lib/rigor/inference/statement_evaluator.rb +106 -6
  56. data/lib/rigor/inference/synthetic_method.rb +86 -0
  57. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  58. data/lib/rigor/inference/synthetic_method_scanner.rb +599 -0
  59. data/lib/rigor/language_server/buffer_table.rb +63 -0
  60. data/lib/rigor/language_server/completion_provider.rb +438 -0
  61. data/lib/rigor/language_server/debouncer.rb +86 -0
  62. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  63. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  64. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  65. data/lib/rigor/language_server/hover_provider.rb +74 -0
  66. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  67. data/lib/rigor/language_server/loop.rb +71 -0
  68. data/lib/rigor/language_server/project_context.rb +145 -0
  69. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  70. data/lib/rigor/language_server/server.rb +384 -0
  71. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  72. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  73. data/lib/rigor/language_server/uri.rb +40 -0
  74. data/lib/rigor/language_server.rb +29 -0
  75. data/lib/rigor/plugin/base.rb +63 -0
  76. data/lib/rigor/plugin/blueprint.rb +60 -0
  77. data/lib/rigor/plugin/loader.rb +3 -1
  78. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  79. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  80. data/lib/rigor/plugin/macro/heredoc_template.rb +315 -0
  81. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  82. data/lib/rigor/plugin/macro.rb +31 -0
  83. data/lib/rigor/plugin/manifest.rb +127 -9
  84. data/lib/rigor/plugin/registry.rb +51 -2
  85. data/lib/rigor/plugin.rb +1 -0
  86. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  87. data/lib/rigor/rbs_extended.rb +82 -2
  88. data/lib/rigor/sig_gen/generator.rb +12 -3
  89. data/lib/rigor/trinary.rb +15 -11
  90. data/lib/rigor/type/app.rb +107 -0
  91. data/lib/rigor/type/bot.rb +6 -3
  92. data/lib/rigor/type/combinator.rb +12 -1
  93. data/lib/rigor/type/integer_range.rb +7 -7
  94. data/lib/rigor/type/refined.rb +18 -12
  95. data/lib/rigor/type/top.rb +4 -3
  96. data/lib/rigor/type.rb +1 -0
  97. data/lib/rigor/type_node/generic.rb +7 -1
  98. data/lib/rigor/type_node/identifier.rb +9 -1
  99. data/lib/rigor/type_node/string_literal.rb +4 -1
  100. data/lib/rigor/version.rb +1 -1
  101. data/sig/rigor/environment.rbs +11 -4
  102. data/sig/rigor/inference.rbs +2 -0
  103. data/sig/rigor/plugin/blueprint.rbs +7 -0
  104. data/sig/rigor/plugin/manifest.rbs +1 -1
  105. data/sig/rigor/plugin/registry.rbs +14 -1
  106. data/sig/rigor.rbs +37 -2
  107. 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"
@@ -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
@@ -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
- Registry.new(plugins: plugins, load_errors: load_errors)
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