riffer 0.43.0 → 0.44.0

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +8 -0
  4. data/Steepfile +1 -0
  5. data/docs/AGENTS.md +23 -2
  6. data/docs/CONFIGURATION.md +32 -0
  7. data/docs/MESSAGES.md +10 -0
  8. data/docs/OVERVIEW.md +1 -0
  9. data/docs/TESTING.md +129 -0
  10. data/docs/TOOLS.md +23 -2
  11. data/docs/providers/AMAZON_BEDROCK.md +17 -0
  12. data/docs/providers/GEMINI.md +2 -3
  13. data/docs-site/manifest.yml +3 -0
  14. data/lib/riffer/config.rb +94 -0
  15. data/lib/riffer/files/downloader.rb +78 -0
  16. data/lib/riffer/files/resolver.rb +97 -0
  17. data/lib/riffer/files.rb +5 -0
  18. data/lib/riffer/helpers/identifier.rb +11 -3
  19. data/lib/riffer/messages/file_part.rb +61 -9
  20. data/lib/riffer/providers/amazon_bedrock.rb +7 -7
  21. data/lib/riffer/providers/base.rb +8 -0
  22. data/lib/riffer/providers/gemini.rb +6 -5
  23. data/lib/riffer/providers/open_ai.rb +6 -0
  24. data/lib/riffer/providers/open_router.rb +6 -0
  25. data/lib/riffer/registrable.rb +105 -22
  26. data/lib/riffer/testing/minitest.rb +21 -0
  27. data/lib/riffer/testing/rspec.rb +11 -0
  28. data/lib/riffer/testing.rb +130 -0
  29. data/lib/riffer/version.rb +1 -1
  30. data/lib/riffer.rb +31 -0
  31. data/sig/_private/minitest.rbs +9 -0
  32. data/sig/_private/riffer/testing/minitest.rbs +6 -0
  33. data/sig/_private/rspec.rbs +7 -0
  34. data/sig/_private/zeitwerk.rbs +2 -0
  35. data/sig/generated/riffer/config.rbs +64 -0
  36. data/sig/generated/riffer/files/downloader.rbs +16 -0
  37. data/sig/generated/riffer/files/resolver.rbs +36 -0
  38. data/sig/generated/riffer/files.rbs +4 -0
  39. data/sig/generated/riffer/helpers/identifier.rbs +8 -0
  40. data/sig/generated/riffer/messages/file_part.rbs +38 -6
  41. data/sig/generated/riffer/providers/amazon_bedrock.rbs +4 -0
  42. data/sig/generated/riffer/providers/base.rbs +4 -0
  43. data/sig/generated/riffer/providers/gemini.rbs +4 -0
  44. data/sig/generated/riffer/providers/open_ai.rbs +4 -0
  45. data/sig/generated/riffer/providers/open_router.rbs +4 -0
  46. data/sig/generated/riffer/registrable.rbs +57 -9
  47. data/sig/generated/riffer/testing/rspec.rbs +2 -0
  48. data/sig/generated/riffer/testing.rbs +75 -0
  49. data/sig/generated/riffer.rbs +32 -0
  50. data/sig/manifest.yaml +1 -0
  51. data/sig/manual/riffer/testing.rbs +5 -0
  52. metadata +31 -1
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "digest"
5
+ require "base64"
6
+
7
+ class Riffer::Files::Resolver
8
+ # @rbs @provider: Riffer::Providers::Base
9
+ # @rbs @config: Riffer::Config::Files
10
+
11
+ #: (provider: Riffer::Providers::Base) -> void
12
+ def initialize(provider:)
13
+ @provider = provider
14
+ @config = Riffer.config.files
15
+ end
16
+
17
+ # Resolves every file in every User message in place - downloading,
18
+ # verifying, and caching as the provider's capability and each file's
19
+ # sha256 require. Raises Riffer::FileError on any file that can't be
20
+ # resolved
21
+ #: (Array[Riffer::Messages::Base]) -> void
22
+ def resolve!(messages)
23
+ files = messages.flat_map do |message|
24
+ next [] unless message.is_a?(Riffer::Messages::User)
25
+
26
+ check_file_count!(message)
27
+ message.files
28
+ end
29
+
30
+ @config.runner.map(files, context: nil) { |file| resolve_file!(file) }
31
+ end
32
+
33
+ private
34
+
35
+ #: (Riffer::Messages::User) -> void
36
+ def check_file_count!(message)
37
+ max = @config.max_per_message
38
+ return if max.nil? || message.files.size <= max
39
+
40
+ raise Riffer::TooManyFilesError, "Too many files specified in user message"
41
+ end
42
+
43
+ #: (Riffer::Messages::FilePart) -> void
44
+ def resolve_file!(file)
45
+ delivery = @provider.file_delivery(file)
46
+ if delivery == :unsupported
47
+ raise Riffer::FileUnsupportedError,
48
+ "Provider does not support user message file attachments"
49
+ end
50
+ return verify_inline!(file) if file.inline_data?
51
+
52
+ case delivery
53
+ when :url
54
+ download!(file, cache: false) if file.sha256
55
+ when :base64
56
+ file.data ? verify_inline!(file) : download!(file, cache: :base64)
57
+ when :bytes
58
+ file.data_bytes ? verify_inline!(file) : download!(file, cache: :bytes)
59
+ else
60
+ raise Riffer::ArgumentError,
61
+ "Unknown file_delivery result #{delivery.inspect} from #{@provider.class}"
62
+ end
63
+ end
64
+
65
+ #: (Riffer::Messages::FilePart) -> void
66
+ def verify_inline!(file)
67
+ return unless file.sha256
68
+
69
+ verify_bytes!(file.data_bytes, file.sha256)
70
+ end
71
+
72
+ # +cache:+ is false for a :url-delivery provider verifying a sha256 — the
73
+ # request still sends the URL, never the downloaded bytes, so caching them
74
+ # would hold memory nothing reads and let later turns skip re-verifying.
75
+ #: (Riffer::Messages::FilePart, cache: (false | Symbol)) -> void
76
+ def download!(file, cache:)
77
+ raise Riffer::FileDownloadsDisabledError, "File attachments are disabled" unless @config.allow_downloads
78
+
79
+ raw = @config.downloader.call(file.url, max_bytes: @config.max_bytes, timeout: @config.timeout)
80
+ verify_bytes!(raw, file.sha256) if file.sha256
81
+
82
+ case cache
83
+ when :base64
84
+ file.cache_downloaded_data(Base64.strict_encode64(raw))
85
+ when :bytes
86
+ file.cache_data_bytes(raw)
87
+ when false
88
+ nil
89
+ end
90
+ end
91
+
92
+ def verify_bytes!(bytes, sha256)
93
+ return if Digest::SHA256.hexdigest(bytes) == sha256
94
+
95
+ raise Riffer::FileChecksumMismatchError, "File checksum mismatch"
96
+ end
97
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Riffer::Files
5
+ end
@@ -29,13 +29,21 @@ module Riffer::Helpers::Identifier
29
29
  cached = klass.instance_variable_get(:@derived_identifier) #: String?
30
30
  return cached if cached
31
31
 
32
- # Tool classes shadow Module#name with the identifier DSL, so the real
33
- # class-path name must come from Module's own implementation.
34
- real_name = Module.instance_method(:name).bind_call(klass) #: String?
32
+ real_name = real_name(klass)
35
33
  return "" if real_name.nil?
36
34
 
37
35
  derived = derive(real_name)
38
36
  klass.instance_variable_set(:@derived_identifier, derived)
39
37
  derived
40
38
  end
39
+
40
+ # Returns the class-path name of a class or module, or +nil+ when anonymous.
41
+ # Tool classes shadow Module#name with the identifier DSL, so the real name
42
+ # must come from Module's own implementation.
43
+ #
44
+ #--
45
+ #: (Module) -> String?
46
+ def real_name(klass)
47
+ Module.instance_method(:name).bind_call(klass) #: String?
48
+ end
41
49
  end
@@ -8,6 +8,9 @@ require "uri"
8
8
  # raw base64 data (+new+).
9
9
  class Riffer::Messages::FilePart
10
10
  # @rbs @url_string: String?
11
+ # @rbs @data: String?
12
+ # @rbs @downloaded_data: String?
13
+ # @rbs @data_bytes: String?
11
14
 
12
15
  MEDIA_TYPES = {
13
16
  ".jpg" => "image/jpeg",
@@ -23,6 +26,7 @@ class Riffer::Messages::FilePart
23
26
  }.freeze #: Hash[String, String]
24
27
 
25
28
  SUPPORTED_MEDIA_TYPES = MEDIA_TYPES.values.uniq.freeze #: Array[String]
29
+ SHA256_PATTERN = /\A[0-9a-f]{64}\z/i #: Regexp
26
30
 
27
31
  # The MIME type of the file.
28
32
  attr_reader :media_type #: String
@@ -30,17 +34,25 @@ class Riffer::Messages::FilePart
30
34
  # The filename, if available.
31
35
  attr_reader :filename #: String?
32
36
 
37
+ # The expected SHA-256 of the file contents, if the caller supplied one.
38
+ attr_reader :sha256 #: String?
39
+
33
40
  # Raises Riffer::ArgumentError unless +data+ or +url+ is given and
34
41
  # +media_type+ is supported.
35
42
  #--
36
- #: (media_type: String, ?data: String?, ?filename: String?, ?url: String?) -> void
37
- def initialize(media_type:, data: nil, filename: nil, url: nil)
43
+ #: (media_type: String, ?data: String?, ?filename: String?, ?url: String?, ?sha256: String?) -> void
44
+ def initialize(media_type:, data: nil, filename: nil, url: nil, sha256: nil)
38
45
  raise Riffer::ArgumentError, "Either data or url must be provided" if data.nil? && url.nil?
39
46
  unless SUPPORTED_MEDIA_TYPES.include?(media_type)
40
47
  raise Riffer::ArgumentError,
41
48
  "Unsupported media type: #{media_type}"
42
49
  end
50
+ unless sha256.nil? || (sha256.is_a?(String) && sha256.match?(SHA256_PATTERN))
51
+ raise Riffer::ArgumentError,
52
+ "Invalid sha256: #{sha256}"
53
+ end
43
54
 
55
+ @sha256 = sha256&.downcase
44
56
  @data = data
45
57
  @media_type = media_type
46
58
  @filename = filename
@@ -50,15 +62,15 @@ class Riffer::Messages::FilePart
50
62
  # Creates a FilePart from a URL, detecting +media_type+ from the path
51
63
  # extension when omitted. Raises Riffer::ArgumentError if it can't be detected.
52
64
  #--
53
- #: (String, ?media_type: String?) -> Riffer::Messages::FilePart
54
- def self.from_url(url, media_type: nil)
65
+ #: (String, ?media_type: String?, ?filename: String?, ?sha256: String?) -> Riffer::Messages::FilePart
66
+ def self.from_url(url, media_type: nil, filename: nil, sha256: nil)
55
67
  unless media_type
56
68
  ext = ::File.extname(URI.parse(url).path.to_s).downcase
57
69
  media_type = MEDIA_TYPES[ext]
58
70
  raise Riffer::ArgumentError, "Cannot detect media type from URL; provide media_type explicitly" unless media_type
59
71
  end
60
72
 
61
- new(url: url, media_type: media_type)
73
+ new(url: url, media_type: media_type, filename: filename, sha256: sha256)
62
74
  end
63
75
 
64
76
  # Builds a FilePart from a +{url:, media_type:}+ or +{data:, media_type:}+ hash,
@@ -75,18 +87,57 @@ class Riffer::Messages::FilePart
75
87
  data = file[:data]
76
88
  media_type = file[:media_type]
77
89
  filename = file[:filename]
90
+ sha256 = file[:sha256]
78
91
 
79
92
  if url
80
- from_url(url, media_type: media_type)
93
+ from_url(url, media_type: media_type, filename: filename, sha256: sha256)
81
94
  elsif data && media_type
82
- new(data: data, media_type: media_type, filename: filename)
95
+ new(data: data, media_type: media_type, filename: filename, sha256: sha256)
83
96
  else
84
97
  raise Riffer::ArgumentError, "File hash must include :url or :data with :media_type"
85
98
  end
86
99
  end
87
100
 
88
- # Returns the base64-encoded data, or nil for URL-only sources.
89
- attr_reader :data #: String?
101
+ # The base64-encoded contents - caller-supplied, or filled in by the file
102
+ # resolver after a download. Nil for a URL source riffer hasn't fetched.
103
+ #--
104
+ #: () -> String?
105
+ def data
106
+ @data || @downloaded_data
107
+ end
108
+
109
+ #: () -> String?
110
+ def data_bytes
111
+ return @data_bytes if @data_bytes
112
+
113
+ encoded = data
114
+ return nil unless encoded
115
+
116
+ @data_bytes = Base64.strict_decode64(encoded.gsub(/\s/, ""))
117
+ rescue ArgumentError
118
+ raise Riffer::FileEncodingError, "Invalid base64 data"
119
+ end
120
+
121
+ #: (String) -> void
122
+ def cache_data_bytes(bytes)
123
+ @data_bytes = bytes
124
+ end
125
+
126
+ # Whether data was supplied directly, as opposed to filled in later by the
127
+ # file resolver after a download
128
+ #: () -> bool
129
+ def inline_data?
130
+ !@data.nil?
131
+ end
132
+
133
+ # Caches bytes fetched for a URL source. Deliberately absent from +to_h+:
134
+ # the agent loop re-sends history on every turn, so the cache saves refreshing
135
+ # the same file, while persisted history stays free of megabytes of base64
136
+ #--
137
+ #: (String) -> void
138
+ def cache_downloaded_data(data)
139
+ @downloaded_data = data
140
+ end
90
141
 
91
142
  # Returns the URL if the source was a URL, nil otherwise.
92
143
  #
@@ -129,6 +180,7 @@ class Riffer::Messages::FilePart
129
180
  hash[:data] = @data if @data
130
181
  hash[:url] = @url_string if @url_string
131
182
  hash[:filename] = filename if filename
183
+ hash[:sha256] = sha256 if sha256
132
184
  hash
133
185
  end
134
186
  end
@@ -43,6 +43,12 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
43
43
  depends_on "aws-sdk-bedrockruntime"
44
44
  end
45
45
 
46
+ #--
47
+ #: (Riffer::Messages::FilePart) -> Symbol
48
+ def file_delivery(file)
49
+ file.url&.start_with?("s3://") ? :url : :bytes
50
+ end
51
+
46
52
  private
47
53
 
48
54
  #--
@@ -413,13 +419,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
413
419
  def convert_file_part_to_bedrock_format(file)
414
420
  format = bedrock_format(file.media_type)
415
421
 
416
- source = if file.data
417
- { bytes: Base64.decode64(file.data) }
418
- elsif file.url&.start_with?("s3://")
419
- { s3_location: { uri: file.url } }
420
- else
421
- raise Riffer::ArgumentError, "Amazon Bedrock only supports S3 URI or base64 data file sources"
422
- end
422
+ source = file.data_bytes ? { bytes: file.data_bytes } : { s3_location: { uri: file.url } }
423
423
 
424
424
  if file.image?
425
425
  { image: { format: format, source: source } }
@@ -48,6 +48,7 @@ class Riffer::Providers::Base
48
48
  @current_model = model
49
49
  messages = normalize_messages(prompt: prompt, system: system, messages: messages, files: files)
50
50
  validate_normalized_messages!(messages)
51
+ Riffer::Files::Resolver.new(provider: self).resolve!(messages)
51
52
  messages = merge_consecutive_messages(messages)
52
53
  params = build_request_params(messages, model, options)
53
54
 
@@ -84,6 +85,7 @@ class Riffer::Providers::Base
84
85
  @current_model = model
85
86
  messages = normalize_messages(prompt: prompt, system: system, messages: messages, files: files)
86
87
  validate_normalized_messages!(messages)
88
+ Riffer::Files::Resolver.new(provider: self).resolve!(messages)
87
89
  messages = merge_consecutive_messages(messages)
88
90
  params = build_request_params(messages, model, options)
89
91
 
@@ -102,6 +104,12 @@ class Riffer::Providers::Base
102
104
  end
103
105
  end
104
106
 
107
+ #--
108
+ #: (Riffer::Messages::FilePart) -> Symbol
109
+ def file_delivery(_file)
110
+ :url
111
+ end
112
+
105
113
  private
106
114
 
107
115
  #: (String) -> true
@@ -27,6 +27,12 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
27
27
  "gcp.gemini"
28
28
  end
29
29
 
30
+ #--
31
+ #: (Riffer::Messages::FilePart) -> Symbol
32
+ def file_delivery(_file)
33
+ :base64
34
+ end
35
+
30
36
  private
31
37
 
32
38
  #--
@@ -274,11 +280,6 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
274
280
  #--
275
281
  #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
276
282
  def convert_file_part_to_gemini_format(file)
277
- if file.url?
278
- raise Riffer::ArgumentError,
279
- "Gemini provider does not support URL-based file references. Provide base64-encoded data instead."
280
- end
281
-
282
283
  { inlineData: { mimeType: file.media_type, data: file.data } }
283
284
  end
284
285
 
@@ -19,6 +19,12 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
19
19
  depends_on "openai"
20
20
  end
21
21
 
22
+ #--
23
+ #: (Riffer::Messages::FilePart) -> Symbol
24
+ def file_delivery(file)
25
+ file.image? ? :url : :base64
26
+ end
27
+
22
28
  private
23
29
 
24
30
  #--
@@ -33,6 +33,12 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
33
33
  depends_on "openai"
34
34
  end
35
35
 
36
+ #--
37
+ #: (Riffer::Messages::FilePart) -> Symbol
38
+ def file_delivery(file)
39
+ file.image? ? :url : :base64
40
+ end
41
+
36
42
  private
37
43
 
38
44
  #--
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- # Registry of a class's named direct subclasses, keyed by identifier. Extend it
4
+ # Registry of a class's direct subclasses, keyed by identifier. Extend it
5
5
  # onto a base class to look up subclasses in constant time via +find+ and +all+.
6
+ # Subclasses join implicitly by inheriting; +register+ adds one explicitly, for
7
+ # ephemeral classes a test suite builds and tears down. Registration is not
8
+ # synchronized — register during boot or from a single-threaded test, before
9
+ # concurrent lookups begin.
6
10
  #
7
11
  # class Riffer::Tool
8
12
  # extend Riffer::Registrable
@@ -13,12 +17,15 @@
13
17
  # @rbs module-self Class
14
18
  module Riffer::Registrable
15
19
  # @rbs @identifier_registry: Hash[String, Class]?
20
+ # @rbs @explicit_registrations: Hash[String, Class]?
16
21
 
17
22
  # Finds a registered subclass by identifier, or +nil+ when none matches.
18
- # Only *named direct* subclasses are registered: grandchildren are not
19
- # visible to a grandparent's +find+ (call +find+ on their direct parent
20
- # instead), anonymous classes are never registered, and duplicate identifiers
21
- # raise Riffer::DuplicateIdentifierError at first lookup.
23
+ # Implicit registration covers only *named direct* subclasses: grandchildren
24
+ # are not visible to a grandparent's +find+ (call +find+ on their direct
25
+ # parent instead), anonymous classes are never registered implicitly, and a
26
+ # subclass whose name no longer resolves back to it is dropped at the next
27
+ # registry rebuild. Duplicate identifiers raise
28
+ # Riffer::DuplicateIdentifierError at first lookup.
22
29
  #
23
30
  #--
24
31
  #: (String | Symbol) -> Class?
@@ -26,10 +33,8 @@ module Riffer::Registrable
26
33
  identifier_registry[identifier.to_s]
27
34
  end
28
35
 
29
- # Returns all registered subclasses. Only *named direct* subclasses are
30
- # registered: grandchildren are not included (call +all+ on their direct
31
- # parent instead), anonymous classes are never registered, and duplicate
32
- # identifiers raise Riffer::DuplicateIdentifierError at first lookup.
36
+ # Returns all registered subclasses, implicit and explicit. Carries the same
37
+ # registration rules as +find+.
33
38
  #
34
39
  #--
35
40
  #: () -> Array[Class]
@@ -37,6 +42,45 @@ module Riffer::Registrable
37
42
  identifier_registry.values
38
43
  end
39
44
 
45
+ # Registers a direct subclass under its +identifier+, whether or not it is
46
+ # named — unlike implicit registration, it survives a name that no longer
47
+ # resolves, so an ephemeral class stays findable until +unregister+. Prefer
48
+ # Riffer::Testing for ordinary test setup, which stubs and cleans up
49
+ # automatically.
50
+ #
51
+ # Raises Riffer::ArgumentError when the identifier is blank or the class is
52
+ # not a direct subclass, and Riffer::DuplicateIdentifierError when the
53
+ # identifier is already taken — including by this same class.
54
+ #
55
+ #--
56
+ #: (Class) -> void
57
+ def register(klass)
58
+ unless klass.superclass.equal?(self)
59
+ raise Riffer::ArgumentError, "#{klass} must be a direct subclass of #{self} to register"
60
+ end
61
+
62
+ key = identifier_key(klass)
63
+ raise Riffer::ArgumentError, "#{klass} must declare a non-blank identifier to register" if key.strip.empty?
64
+
65
+ existing = identifier_registry[key]
66
+ raise_duplicate_identifier!(key, existing, klass) if existing
67
+
68
+ explicit_registrations[key] = klass
69
+ @identifier_registry = nil
70
+ end
71
+
72
+ # Removes an explicit registration of +klass+, leaving implicit registrations
73
+ # untouched.
74
+ #--
75
+ #: (Class) -> void
76
+ def unregister(klass)
77
+ key, = explicit_registrations.find { |_key, registered| registered.equal?(klass) }
78
+ return if key.nil?
79
+
80
+ explicit_registrations.delete(key)
81
+ @identifier_registry = nil
82
+ end
83
+
40
84
  private
41
85
 
42
86
  # Ruby invokes +inherited+ with +self+ bound to the direct superclass — the
@@ -55,27 +99,66 @@ module Riffer::Registrable
55
99
  @identifier_registry ||= build_identifier_registry
56
100
  end
57
101
 
102
+ #--
103
+ #: () -> Hash[String, Class]
104
+ def explicit_registrations
105
+ @explicit_registrations ||= {}
106
+ end
107
+
58
108
  #--
59
109
  #: () -> Hash[String, Class]
60
110
  def build_identifier_registry
61
- registry = {} #: Hash[String, Class]
62
- subclasses.each_with_object(registry) do |subclass, acc|
63
- # Anonymous classes are skipped even with an explicit identifier — the
64
- # MCP factory and serializer shells synthesize short-lived anonymous
65
- # classes whose registration would flake with GC timing.
66
- next if Riffer::Helpers::Identifier.for(subclass).empty?
67
-
68
- candidate = subclass #: untyped
69
- key = candidate.identifier.to_s
111
+ subclasses.each_with_object(explicit_registrations.dup) do |subclass, acc|
112
+ next unless live?(subclass)
113
+
114
+ key = identifier_key(subclass)
70
115
  next if key.strip.empty?
71
116
 
72
117
  existing = acc[key]
73
- if existing
74
- raise Riffer::DuplicateIdentifierError,
75
- "Duplicate identifier #{key.inspect} for #{existing} and #{subclass}"
76
- end
118
+ raise_duplicate_identifier!(key, existing, subclass) if existing && !existing.equal?(subclass)
77
119
 
78
120
  acc[key] = subclass
79
121
  end.freeze
80
122
  end
123
+
124
+ # Class#subclasses keeps returning superseded generations of a reloaded or
125
+ # stubbed class, so a subclass counts only while its own name still resolves
126
+ # back to it. An anonymous class has no name to resolve and is skipped even
127
+ # with an explicit identifier — the MCP factory and serializer shells
128
+ # synthesize short-lived anonymous classes whose registration would flake
129
+ # with GC timing.
130
+ #--
131
+ #: (Class) -> bool
132
+ def live?(subclass)
133
+ real_name = Riffer::Helpers::Identifier.real_name(subclass)
134
+ return false if real_name.nil?
135
+
136
+ # Module#autoload? does not traverse a qualified path, so each segment is
137
+ # resolved against its own owner: probing a pending autoload would trigger
138
+ # the load, whose +inherited+ hook busts the memo this build is populating.
139
+ root = Object #: Module
140
+ resolved = real_name.split("::").reduce(root) do |owner, segment|
141
+ return false if owner.autoload?(segment, false)
142
+
143
+ owner.const_get(segment, false) #: Module
144
+ end
145
+
146
+ resolved.equal?(subclass)
147
+ rescue NameError
148
+ false
149
+ end
150
+
151
+ #--
152
+ #: (Class) -> String
153
+ def identifier_key(klass)
154
+ candidate = klass #: untyped
155
+ candidate.identifier.to_s
156
+ end
157
+
158
+ #--
159
+ #: (String, Class, Class) -> void
160
+ def raise_duplicate_identifier!(key, existing, klass)
161
+ raise Riffer::DuplicateIdentifierError,
162
+ "Duplicate identifier #{key.inspect} for #{existing} and #{klass}"
163
+ end
81
164
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs-inline stays disabled here: the generated signature would name Minitest,
4
+ # which a shipped signature must never reference. The hand-written stub lives in
5
+ # sig/_private/riffer/testing/minitest.rbs.
6
+
7
+ require "riffer"
8
+
9
+ # Wiring only — minitest is never a riffer dependency; a consumer requires this
10
+ # file from a test_helper that has already loaded the framework.
11
+ module Riffer::Testing::MinitestCleanup
12
+ # Minitest reserves +after_teardown+ for library extensions; +teardown+
13
+ # belongs to the test author.
14
+ def after_teardown
15
+ Riffer::Testing.reset!
16
+ super
17
+ end
18
+ end
19
+
20
+ Minitest::Test.include(Riffer::Testing)
21
+ Minitest::Test.include(Riffer::Testing::MinitestCleanup)
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "riffer"
5
+
6
+ # Wiring only — RSpec is never a riffer dependency; a consumer requires this
7
+ # file from a spec_helper that has already loaded the framework.
8
+ RSpec.configure do |config|
9
+ config.include Riffer::Testing
10
+ config.after { Riffer::Testing.reset! }
11
+ end