adornment-core 0.1.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.
data/README.md ADDED
@@ -0,0 +1,669 @@
1
+ # adornment-core
2
+
3
+ The declarative metadata engine behind Adornment: annotation types, declarations above `def`,
4
+ attachment, reflection along Ruby's method resolution, verification and diagnostics.
5
+
6
+ ```ruby
7
+ require "adornment/core"
8
+
9
+ class Traced
10
+ include Adornment::Annotation[]
11
+
12
+ attr_reader :span
13
+
14
+ def initialize(span: nil)
15
+ @span = span
16
+ end
17
+ end
18
+
19
+ class Orders
20
+ include Adornment[Traced]
21
+
22
+ Traced(span: "orders.fetch")
23
+ def fetch(id) = id
24
+ end
25
+
26
+ Orders.annotations_for(:fetch) # => [#<Traced span="orders.fetch">]
27
+ ```
28
+
29
+ - Annotations are frozen values you reflect on. **Core never changes how a method runs**: no
30
+ wrappers, no method replacement, nothing prepended to the owner. A call to an annotated method
31
+ executes no Adornment code and allocates nothing extra.
32
+ - Pure Ruby, no runtime dependencies. Prism (a default gem since Ruby 3.3) is loaded only to run
33
+ the lint. Declarations never read source files.
34
+ - CRuby only, Ruby >= 3.3 (see [Ruby versions](#ruby-versions) for what was actually tested).
35
+ - Version 0.1.0, the first release.
36
+
37
+ Core owns annotation types, declarations, attachment, reflection, verification and diagnostics.
38
+ HTTP, operation execution, persistence, coercion and runtime type checking belong to the gems
39
+ built on it (`adornment-api`, `adornment-operation`, `adornment-repo`, ...).
40
+
41
+ ### Gem names
42
+
43
+ | gem | require | role |
44
+ |---|---|---|
45
+ | `adornment-core` (this gem) | `require "adornment/core"` | the engine |
46
+ | `adornment` | `require "adornment"` | the umbrella gem: loads every part |
47
+
48
+ Everything lives in the `Adornment` namespace.
49
+
50
+ ## The one pitfall: write `Traced()`, not `Traced`
51
+
52
+ A capitalized name with no arguments and no parentheses is a **constant read** in Ruby: it
53
+ declares nothing.
54
+
55
+ ```ruby
56
+ Traced # WRONG: a constant read
57
+ def a = 1
58
+
59
+ Traced() # right
60
+ def b = 1
61
+
62
+ Traced span: "x" # right: arguments make it a method call
63
+ def c = 1
64
+ ```
65
+
66
+ Ruby gives no hook for a constant read. The [lint](#lint) finds these after the fact, and
67
+ `Adornment.verify_all!` runs it by default.
68
+
69
+ ## Defining types
70
+
71
+ Core has one kind of annotation: method annotations, written above `def`. Per-parameter metadata
72
+ is an extension's own business ([Per-parameter metadata in extensions](#per-parameter-metadata-in-extensions)).
73
+
74
+ An annotation type is an ordinary named class that includes `Adornment::Annotation[...]`. The
75
+ brackets take the type's policies; `Adornment::Annotation[]` takes the defaults, and a bare
76
+ `include Adornment::Annotation` raises. Fields are instance variables set by an ordinary
77
+ `initialize`: Ruby itself decides what is positional, what is a keyword, what is required and
78
+ what the defaults are.
79
+
80
+ ### Method annotation types
81
+
82
+ ```ruby
83
+ require "adornment/core"
84
+
85
+ class Route
86
+ include Adornment::Annotation[repeatable: true] # several per method (default: false)
87
+
88
+ attr_reader :path, :verbs
89
+
90
+ def initialize(path, verbs: [:GET]) # path positional, verbs a keyword with a default
91
+ @path = path
92
+ @verbs = verbs.map(&:to_sym) # normalize freely: Adornment freezes afterwards
93
+ end
94
+ end
95
+
96
+ class Raises
97
+ include Adornment::Annotation[targets: [:instance]]
98
+
99
+ attr_reader :errors
100
+
101
+ def initialize(errors)
102
+ @errors = errors
103
+ end
104
+ end
105
+
106
+ raise unless Route.new("/users").verbs == [:GET]
107
+ raise unless Route.new("/users", verbs: ["POST"]) == Route.new("/users", verbs: [:POST])
108
+ raise unless Route.new("/x").frozen? && Route.new("/x", verbs: [+"put"]).verbs.frozen?
109
+ raise unless Raises.new([KeyError, IndexError]).errors == [KeyError, IndexError]
110
+ raise unless Route.repeatable? && Raises.targets == [:instance]
111
+ ```
112
+
113
+ Policies of method annotation types (all optional; each `include` stores only what it is given):
114
+
115
+ | Policy | Values | Default |
116
+ |---|---|---|
117
+ | `repeatable:` | `true` or `false` | `false` |
118
+ | `targets:` | a non-empty subset of `[:instance, :singleton]` | both |
119
+
120
+ Construction rules:
121
+
122
+ - Declaration methods (`Route "/users", verbs: [:GET]`) call `Type.new` with the arguments as
123
+ written. Wrong arguments raise Ruby's
124
+ own `ArgumentError` (`wrong number of arguments`, `missing keyword: :path`,
125
+ `unknown keyword: :colour`) from the initializer.
126
+ - After `initialize` returns, `new` applies the [freezing rule](#immutability) to every instance
127
+ variable and freezes the instance. Do not freeze in the initializer: an instance that is already
128
+ frozen is kept as it is, and deep freezing is then the initializer's job.
129
+ - Every instance variable is a field: it takes part in `==` (same class, equal fields), `eql?`,
130
+ `hash`, `to_h`, inspection (`#<Route path="/users", verbs=[:GET]>`) and pattern matching
131
+ (`in { path: String => path }`). Keep helpers and caches out of instance variables.
132
+ - There is no `with`: build a new instance.
133
+ - `Type[...]` is not a constructor: it raises `Adornment::TargetError`, which shows the call form
134
+ (`Get["/x"]` → `Get("/x")`), so a misplaced bracket form fails loudly.
135
+ - Subclasses inherit the policies. A subclass may include `Adornment::Annotation[...]` again to
136
+ override some of them: values replace inherited ones (arrays are not concatenated).
137
+
138
+ ## Activating a class or module
139
+
140
+ ```ruby
141
+ require "adornment/core"
142
+ class Traced
143
+ include Adornment::Annotation[]
144
+
145
+ attr_reader :span
146
+
147
+ def initialize(span: nil)
148
+ @span = span
149
+ end
150
+ end
151
+
152
+ class A
153
+ include Adornment[Traced] # declaration method named after the type: Traced(...)
154
+ Traced(span: "a")
155
+ def a = 1
156
+ end
157
+
158
+ class B
159
+ include Adornment[Trace: Traced] # an alias names the declaration method
160
+ Trace(span: "b")
161
+ def b = 1
162
+ end
163
+
164
+ class C
165
+ include Adornment[] # no declaration methods
166
+ adorn Traced.new(span: "c") # instances only: build them first, reuse them freely
167
+ def c = 1
168
+ end
169
+
170
+ class D
171
+ extend Adornment::Core::Target # the explicit, low-level form
172
+ use_annotations Traced
173
+ end
174
+
175
+ raise unless [A.annotations_for(:a), B.annotations_for(:b), C.annotations_for(:c)].map { |l| l.first.span } == %w[a b c]
176
+ raise unless !Object.respond_to?(:Traced) # nothing is defined globally
177
+ ```
178
+
179
+ - Declaration methods are singleton methods of the owner, and its subclasses inherit them.
180
+ Nothing is added to `Object`, `Module` or unrelated classes.
181
+ - `include Adornment` (bare), `extend Adornment[...]` and `prepend Adornment[...]` raise
182
+ `ArgumentError`.
183
+ - A class that is not an annotation type raises `ArgumentError` ("X is not an annotation type;
184
+ include Adornment::Annotation[...]"); so does `adorn Route` (a type, not an instance: write
185
+ `adorn Route.new(...)`) and `adorn` with any object that is not an annotation instance.
186
+ - A declaration method `Route("/x")` is `adorn Route.new("/x")`: both record the user's line
187
+ once, and the declaration's location travels with it until it attaches.
188
+
189
+ ## Which definitions take a declaration
190
+
191
+ Pending declarations attach, in order, to the **next method definition in the same owner, made
192
+ from the same fiber**, whatever makes that definition.
193
+
194
+ | Construct | Takes the declarations? |
195
+ |---|---|
196
+ | `def`, endless `def`, `private def`, `protected def`, `def` in `class_eval` (string or block) | yes |
197
+ | `define_method` with a block, Proc, Method or UnboundMethod | yes |
198
+ | `def self.x`, `define_singleton_method` | yes, on the singleton side (unless the type narrows `targets` to `:instance`) |
199
+ | `attr_reader`, `attr_writer`, `attr_accessor` | yes: the first method generated (`a` for `attr_accessor :a`) |
200
+ | `alias`, `alias_method` (also in `class << self`) | yes: an alias is annotated like any method (see [Aliases](#aliases)) |
201
+ | `private :inherited_method`, `private_class_method :inherited`, `module_function :inherited` | yes: Ruby makes an own entry in this owner, an override that runs the ancestor's body |
202
+ | `module_function :name` singleton copy | yes, on the singleton side: a copy, like an alias (the instance method keeps its own) |
203
+ | any other method defined in between, e.g. by a macro | **yes** |
204
+
205
+ - A definition takes the pending declarations; a definition with nothing pending changes
206
+ nothing. Redefining a method with declarations replaces its annotations; redefining it without
207
+ declarations keeps them. Removal is not tracked: after `remove_method` or `undef_method` the
208
+ method's annotations are hidden from reflection while the owner does not define it, and an
209
+ unannotated redefinition shows them again. Pending declarations are untouched by removals.
210
+ - A non-repeatable type declared twice for one method raises `Adornment::DuplicateAnnotation`.
211
+ A type declared for the wrong kind of method raises `Adornment::InvalidTarget`. Both clear the
212
+ pending declarations.
213
+ - Hooks: core prepends `method_added`, `singleton_method_added` and `const_added` to an
214
+ activated owner's singleton class, once per ancestry (subclasses inherit them). Like any Ruby
215
+ hook, an owner's own `self.method_added` must call `super`. A subclass's own hook runs before
216
+ the inherited one, so a declaration it makes before `super` attaches to the method being
217
+ defined.
218
+
219
+ A declaration cannot reach methods generated by a macro or the second name of an
220
+ `attr_reader :a, :b`; to annotate such a method, define it with `def` or `define_method`.
221
+
222
+ ### Aliases
223
+
224
+ An alias starts with no annotations (nothing is copied from the original) and takes the
225
+ declarations above it like any definition, whether it comes from `alias`, `alias_method` or
226
+ `define_method(name, instance_method(other))`. An alias has the original's body, so it shares
227
+ the original's signature, but not its annotations: an alias that needs a parameter declaration
228
+ takes its own above it.
229
+
230
+ ```ruby
231
+ require "adornment/core"
232
+ class Route
233
+ include Adornment::Annotation[]
234
+
235
+ attr_reader :path
236
+
237
+ def initialize(path)
238
+ @path = path
239
+ end
240
+ end
241
+
242
+ class UsersApi
243
+ include Adornment[Route]
244
+
245
+ Route("/users/:id")
246
+ def show(id) = id
247
+
248
+ Route("/profile/:id") # attaches to profile only
249
+ alias_method :profile, :show
250
+
251
+ alias helper show # unannotated: a plain Ruby method
252
+ end
253
+
254
+ raise unless UsersApi.annotations_for(:show) == [Route.new("/users/:id")]
255
+ raise unless UsersApi.annotations_for(:profile) == [Route.new("/profile/:id")]
256
+ raise unless UsersApi.annotations_for(:helper).empty?
257
+ ```
258
+
259
+ ## Leftovers: declarations that never attach
260
+
261
+ Pending declarations belong to one owner and wait for that owner's
262
+ next method definition. Nothing traces body ends, so these raise `Adornment::LeftoverAnnotations`,
263
+ naming the owner, the annotation and where it was declared, and clear the pending declarations:
264
+
265
+ - a declaration on another owner while declarations are still pending;
266
+ - a constant added to the owner while it has pending declarations (a nested `class Inner`,
267
+ `LIMIT = 10`).
268
+
269
+ A leftover at the end of a body is not raised there, so it never masks an exception unwinding the
270
+ body. It stays pending until one of the above, the owner's next definition (also in a later
271
+ reopening) or verification. `Owner.verify_adornments!` checks one owner and
272
+ `Adornment.verify_all!` checks everything pending. Call it once your code is loaded, for example
273
+ at the end of boot or in a test.
274
+
275
+ ```ruby
276
+ require "adornment/core"
277
+ class Tag
278
+ include Adornment::Annotation[]
279
+
280
+ attr_reader :name
281
+
282
+ def initialize(name)
283
+ @name = name
284
+ end
285
+ end
286
+
287
+ begin
288
+ class Service
289
+ include Adornment[Tag]
290
+ Tag("fetch")
291
+ class Inner; end # const_added(:Inner) while Tag is pending
292
+ def fetch = 1
293
+ end
294
+ rescue Adornment::LeftoverAnnotations => e
295
+ raise unless e.leftovers.keys == [Service] && e.message.include?("declared at")
296
+ end
297
+
298
+ Built = Class.new { include Adornment[Tag]; Tag("in a block body") }
299
+ begin
300
+ Class.new { include Adornment[Tag]; Tag("another owner") }
301
+ raise "expected a leftover"
302
+ rescue Adornment::LeftoverAnnotations => e
303
+ raise unless e.leftovers.keys == [Built]
304
+ end
305
+ ```
306
+
307
+ ## Reflection
308
+
309
+ ```ruby
310
+ require "adornment/core"
311
+ class Traced
312
+ include Adornment::Annotation[]
313
+
314
+ attr_reader :span
315
+
316
+ def initialize(span: nil)
317
+ @span = span
318
+ end
319
+ end
320
+
321
+ class Tag
322
+ include Adornment::Annotation[repeatable: true]
323
+
324
+ attr_reader :name
325
+
326
+ def initialize(name)
327
+ @name = name
328
+ end
329
+ end
330
+
331
+ class Parent
332
+ include Adornment[Traced, Tag]
333
+
334
+ Traced(span: "p")
335
+ Tag("a")
336
+ Tag("b")
337
+ def fetch = 1
338
+ end
339
+
340
+ class Child < Parent
341
+ def fetch = super # unannotated override
342
+ end
343
+
344
+ class Heir < Parent; end # no override: Parent#fetch is the resolved method
345
+
346
+ raise unless Parent.annotation(:fetch, Traced) == Traced.new(span: "p")
347
+ raise unless Parent.annotated_with(Tag) == { fetch: [Tag.new("a"), Tag.new("b")] }
348
+ raise unless Heir.annotation(:fetch, Traced) == Traced.new(span: "p")
349
+ raise unless Child.annotations_for(:fetch).empty? && Child.annotation(:fetch, Traced).nil? # strict
350
+ raise unless Child.find_annotation(:fetch, Traced) == Traced.new(span: "p") # walks ancestors
351
+ raise unless Child.declarations_for(:fetch).all? { |d| d.owner == Parent && !d.resolved }
352
+ ```
353
+
354
+ | Owner method | Returns |
355
+ |---|---|
356
+ | `annotations_for(name, type = nil, singleton: false)` | the resolved method's annotations |
357
+ | `annotation(name, type, singleton: false)` | the resolved method's annotation of `type`, or nil (raises for repeatable types) |
358
+ | `find_annotation(name, type, singleton: false)` | the same, falling back to the nearest overridden method that has one (raises for repeatable types) |
359
+ | `annotated_with(type, singleton: false)` | `{name => annotation}`, or `{name => [annotations]}` for repeatable types |
360
+ | `own_annotations_for(name, type = nil, singleton: false)` | only this owner's annotations |
361
+ | `own_annotated_methods(singleton: false)` | the names this owner annotated and still defines, in first-annotation order |
362
+ | `declarations_for(name, singleton: false)` | every `Adornment::Core::Declaration` (`annotation`, `owner`, `method_name`, `singleton`, `resolved`) along the ancestors |
363
+ | `pending_adornments` | annotations still waiting for a definition (current fiber) |
364
+
365
+ - Everything is relative to the **resolved method**: the body Ruby's lookup reaches. Its owner is
366
+ the defining owner, so for a method from an included module, the module.
367
+ - Queries are strict, as in Java: an override does not carry the annotations of the method it
368
+ overrides. `find_annotation` walks the overridden methods, nearest first (like Spring's
369
+ `findAnnotation`); `declarations_for` lists all of them.
370
+ - A visibility change (`private :fetch` in a subclass) is an override like any other: it has its
371
+ own annotations (none unless declared), and `find_annotation` reaches the parent's.
372
+ `undef_method` stops reflection; `remove_method` falls through to ancestors.
373
+ - Classes and modules that include, prepend or extend an activated module get this read-only API
374
+ automatically, without declaration methods.
375
+ - Results are frozen. Reflection never locks. It is meant for boot and compilation, not for each
376
+ request: extensions compile what they need once.
377
+
378
+ ### Discovery
379
+
380
+ `Adornment.annotated_with(type, singleton: false)` searches every registered owner and returns the
381
+ *defining* owners: `{Owner => {name => annotation(s)}}`. Owners that only inherit annotated
382
+ methods are not listed; ask them with `Owner.annotated_with(type)`. Load your code first: a class
383
+ that was never loaded cannot be found. `Adornment.registered_owners` lists the activated owners
384
+ that are still reachable. The registry holds them weakly, so dropped classes can be collected.
385
+
386
+ ```ruby
387
+ require "adornment/core"
388
+ class Route
389
+ include Adornment::Annotation[]
390
+
391
+ attr_reader :path
392
+
393
+ def initialize(path)
394
+ @path = path
395
+ end
396
+ end
397
+
398
+ class Users
399
+ include Adornment[Route]
400
+ Route("/users")
401
+ def index = 1
402
+ end
403
+
404
+ class Admins < Users; end # inherits index, declares nothing
405
+
406
+ raise unless Adornment.annotated_with(Route) == { Users => { index: Route.new("/users") } }
407
+ raise unless Admins.annotated_with(Route) == { index: Route.new("/users") }
408
+ Adornment.unregister(Users)
409
+ raise unless Adornment.annotated_with(Route).empty? && Users.annotations_for(:index) == [Route.new("/users")]
410
+ ```
411
+
412
+ `Adornment.unregister(owner)` only removes the owner from discovery and from `verify_all!`. Its
413
+ hooks stay installed and its metadata stays readable through its own reflection API. Declaring on
414
+ it again does not register it again; activating it again (`include Adornment[...]`) does.
415
+
416
+ ## Attachment-time validation
417
+
418
+ An annotation type may define `validate(site)`. It runs whenever an annotation of the type is
419
+ about to attach, after the target and duplicate checks and before anything is written; it reads
420
+ and raises, never mutates. `site` answers `owner`, `name`, `method` (an `UnboundMethod`),
421
+ `annotations` (the complete proposed set, frozen), `instance?` and `singleton?`. One site is
422
+ shared by every annotation of the definition.
423
+
424
+ ```ruby
425
+ require "adornment/core"
426
+
427
+ class Reader
428
+ include Adornment::Annotation[]
429
+
430
+ def validate(site)
431
+ raise ArgumentError, "annotates a reader without parameters" unless site.method.parameters.empty?
432
+ raise ArgumentError, "cannot be combined with Writer" if site.annotations.any?(Writer)
433
+ end
434
+ end
435
+
436
+ class Writer
437
+ include Adornment::Annotation[]
438
+ end
439
+
440
+ class Model
441
+ include Adornment[Reader, Writer]
442
+
443
+ Reader()
444
+ def name = "n"
445
+ end
446
+
447
+ begin
448
+ Model.class_eval do
449
+ Reader()
450
+ def age(unit) = unit
451
+ end
452
+ rescue Adornment::DefinitionError => e
453
+ raise unless e.message.start_with?("Model#age: annotates a reader") && e.cause.is_a?(ArgumentError)
454
+ end
455
+ raise unless Model.annotations_for(:age).empty? && Model.pending_adornments.empty?
456
+
457
+ begin
458
+ Model.class_eval do
459
+ Reader()
460
+ Writer()
461
+ def title = "t"
462
+ end
463
+ rescue Adornment::DefinitionError => e
464
+ raise unless e.message.include?("cannot be combined")
465
+ end
466
+ raise unless Model.annotations_for(:title).empty?
467
+ ```
468
+
469
+ - Every `StandardError` from a validator becomes `Adornment::DefinitionError`, with the original
470
+ as `cause`. The message starts with `Owner#method` (`Owner.method` for singleton methods) and
471
+ ends with `(declared at path:line)`; that location is also the first backtrace entry.
472
+ - On failure nothing is attached and no declaration stays pending; the method itself stays
473
+ defined, and defining it again (with or without declarations) works as usual.
474
+ - Validators run outside the owner's lock.
475
+
476
+ ## Per-parameter metadata in extensions
477
+
478
+ Core has no parameter annotations: no attachment, signature checking or resolution for
479
+ parameters. An extension that needs per-parameter metadata keeps its own ordinary value objects
480
+ inside one of its method annotations, and reads them back with the reflection API and Ruby's
481
+ `Method#parameters`. adornment-api's `Params` is built this way; the minimal form is:
482
+
483
+ ```ruby
484
+ require "adornment/core"
485
+
486
+ module Vocabulary
487
+ # Ordinary metadata: core knows nothing about it.
488
+ Query = Data.define(:type, :default) do
489
+ def initialize(type:, default: nil) = super
490
+ end
491
+ end
492
+
493
+ class Args # the extension's own declaration
494
+ include Adornment::Annotation[]
495
+
496
+ attr_reader :parameters
497
+
498
+ def initialize(parameters)
499
+ @parameters = parameters
500
+ end
501
+ end
502
+
503
+ class Items
504
+ include Vocabulary # makes Query resolvable, as for any constant
505
+ include Adornment[Args]
506
+
507
+ Args({ page: Query.new(Integer, 1), per: Query.new(Integer) })
508
+ def index(page:, per: 20) = [page, per]
509
+ end
510
+
511
+ declared = Items.annotation(:index, Args).parameters
512
+ raise unless declared == { page: Vocabulary::Query.new(Integer, 1), per: Vocabulary::Query.new(Integer) }
513
+ raise unless declared.frozen? # the freezing rule
514
+ raise unless Items.instance_method(:index).parameters == [%i[keyreq page], %i[key per]]
515
+ raise unless Items.new.index(page: 2) == [2, 20] # core never touches the method
516
+ ```
517
+
518
+ - Everything here is plain core: the declaration is an ordinary method annotation (attached to the
519
+ next definition, strict reflection, non-repeatable unless the type says otherwise), and its
520
+ values follow the [freezing rule](#immutability): the Hash is copied and frozen, the metadata
521
+ objects are kept as they are (make them immutable yourself, as `Data` is).
522
+ - The metadata objects are not annotations: `adorn`, `include Adornment[...]` and
523
+ `annotated_with` reject them.
524
+ - What the declaration means (which parameters it must cover, what a missing value becomes, how
525
+ values are resolved) is the extension's decision; core reads no source files and never looks at
526
+ signature defaults.
527
+ - An extension checks a declaration against the method in its type's `validate(site)` (see
528
+ [Attachment-time validation](#attachment-time-validation)), or from its own `method_added` hook
529
+ on the classes it activates (calling `super`, as for any hook), using `pending_adornments`
530
+ before `super` and reflection after it.
531
+
532
+ ## Lint
533
+
534
+ `Adornment.lint(*owners_or_paths)` parses source with Prism. It flags statements that sit directly
535
+ in a class or module body, consist only of a constant (`Traced` or `A::Traced`), and match a
536
+ declaration method or type registered on that owner. It evaluates nothing and cannot know intent.
537
+ Suppress a diagnostic with `# adornment:ignore` on the same line.
538
+
539
+ `Adornment.verify_all!(lint: :warn)` runs it. `:warn` (the default) prints the diagnostics,
540
+ `:error` raises `Adornment::LintError`, and `false` skips the lint.
541
+
542
+ ## Public API
543
+
544
+ This is the supported surface. Extension gems need nothing else, and
545
+ [`examples/events`](examples/events/events.rb) is a working extension that uses only this.
546
+
547
+ **`Adornment.*`** (all declared in `lib/adornment/core.rb`):
548
+
549
+ | | |
550
+ |---|---|
551
+ | `Adornment[*types, **aliases]` | a module whose `include` activates the includer |
552
+ | `activated?(owner)` | whether `owner` (or an ancestor) is activated |
553
+ | `annotated_with(type, singleton: false)` | global discovery over registered owners |
554
+ | `registered_owners` | the registered, reachable owners |
555
+ | `verify_all!(lint: :warn)` | report every leftover (and lint) at once |
556
+ | `lint(*owners_or_paths)` | the bare-constant lint |
557
+ | `unregister(owner)` | remove an owner from discovery and verification |
558
+ | `immutable_copy(value)` | the freezing rule, for extension values |
559
+
560
+ **Owner API:** `Adornment::Core::Target` provides `use_annotations`, `adorn` and
561
+ `verify_adornments!`. Activation also extends the owner with `Adornment::Core::Reflection`,
562
+ which provides `annotations_for`, `own_annotations_for`, `annotation`, `find_annotation`,
563
+ `annotated_with`, `own_annotated_methods`, `declarations_for` and `pending_adornments`.
564
+ Declaration methods (`Traced(...)`) are defined per owner.
565
+
566
+ **Types:** `Adornment::Annotation[...]` defines an annotation type (included in a plain class);
567
+ `Adornment::Annotation` is also the marker module of its instances. Every type answers
568
+ `short_name`, `new` (Ruby's construction, then the freezing rule), `repeatable?`, `targets` and
569
+ `[]` (raises `TargetError`). Instances answer `validate(site)` (a no-op unless the type defines
570
+ it), `==`, `eql?`, `hash`, `to_h` and `deconstruct_keys`.
571
+
572
+ **Records:** `Adornment::Core::Declaration`, `Adornment::Core::Pending::PendingDeclaration`
573
+ (`annotation`, `location`), `Adornment::Core::Site` and `Adornment::Core::Lint::Diagnostic`.
574
+ `Adornment[...]` returns an `Adornment::Core::Base`.
575
+
576
+ **Errors:** `Adornment::Error`, with the subclasses `DuplicateAnnotation`, `InvalidTarget`,
577
+ `TargetError`, `DefinitionError`, `LeftoverAnnotations` (`leftovers`) and `LintError`
578
+ (`diagnostics`), plus
579
+ `Adornment::UnsupportedPlatformError` (a `LoadError`).
580
+
581
+ The names users write every day (`Adornment[...]`, `Adornment::Annotation`, the errors) live
582
+ directly under `Adornment`; the owner API and records above live under `Adornment::Core`.
583
+ Everything else under `Adornment::Core` (state, registry, hooks, pending store) is internal and
584
+ may change in any release.
585
+
586
+ ## Threads, fibers and immutability
587
+
588
+ - Pending declarations are **fiber-local**, keyed by the owner (by identity); one owner at a time
589
+ per fiber.
590
+ - Reflection never locks: entries are frozen Arrays, and whole-side reads get a frozen snapshot.
591
+ Writes take the owner's lock. No user code runs under it.
592
+ - Defining annotated methods on one class from several threads at once does not lose or corrupt
593
+ registrations, but the ordering is unspecified. **Concurrent definition is unsupported.**
594
+
595
+ ### Immutability
596
+
597
+ Annotation instances are frozen. Array, Hash, Set and String values are stored as deep-frozen
598
+ copies, so your objects are never mutated or frozen. A value that is already deeply immutable
599
+ (`Ractor.shareable?`) is kept as it is. Other objects are kept by reference. Use
600
+ `Ractor.shareable?(annotation)` if you need full shareability.
601
+
602
+ The rule is applied by `new` after the initializer returns, to every instance variable: so it
603
+ covers what the initializer builds while normalizing (`@values = values.map(&:to_s)` stores a
604
+ frozen Array of frozen Strings). Defaults written in the initializer are copied like any value;
605
+ a frozen constant default is kept as it is. An initializer that freezes the instance itself opts
606
+ out: the instance is then kept as it is.
607
+
608
+ ## How it works, and what it costs
609
+
610
+ - Definitions are observed through `method_added`, `singleton_method_added` and `const_added` of
611
+ activated owners only.
612
+ - A definition with no pending declaration for its owner takes a path that allocates nothing and
613
+ looks up no method body. A definition on an activated owner costs well under a microsecond more
614
+ than on a plain class; see [benchmarks](docs/benchmarks.md).
615
+ - Nothing is traced: no TracePoint is ever enabled. Leftovers are found by the next declaration
616
+ on another owner, by `const_added` on the owner, or by verification.
617
+ - Calls to annotated methods run the method itself. The specs check owner, source location, zero
618
+ extra allocations, and that no Adornment code runs during calls.
619
+ - `require "adornment/core"` loads only the declaration and reflection engine. The lint (and
620
+ Prism) is autoloaded on first use.
621
+
622
+ [docs/architecture.md](docs/architecture.md) explains the internals and the techniques taken from
623
+ sorbet-runtime.
624
+
625
+ ## Limitations
626
+
627
+ 1. **CRuby only.** Lock-free reads rely on GVL-atomic `Hash#[]` with Symbol keys.
628
+ 2. **A leftover at the end of a body is not reported there.** It is reported by the next
629
+ declaration on another owner, a constant added to the owner, or verification; until then it
630
+ attaches to the owner's next definition from the same fiber, also in a later reopening.
631
+ 3. **Reopening an existing nested class** (`class Inner` when `Inner` is already defined) adds no
632
+ constant, so it does not report the outer owner's pending declarations. Declarations left in a
633
+ fiber that finishes are dropped with it.
634
+ 4. **A macro between a declaration and its `def`** takes the declaration if it defines a method.
635
+ 5. **`attr_*` with several names** attaches to the first generated method only.
636
+ 6. **Concurrent definition** of one owner from several threads is unsupported. Pending
637
+ declarations are fiber-local, so other fibers and threads never see them.
638
+ 7. **No per-parameter metadata.** Core neither attaches nor checks anything per parameter;
639
+ extensions do (see [Per-parameter metadata in extensions](#per-parameter-metadata-in-extensions)).
640
+ 8. **Validators see attachments, not every redefinition.** An unannotated redefinition keeps the
641
+ old annotations without running `validate` again; check what matters again where you compile.
642
+
643
+ ## Ruby versions
644
+
645
+ Declared: CRuby >= 3.3 (`required_ruby_version`). Actually tested for this release:
646
+
647
+ | Ruby | YJIT off | YJIT on | where |
648
+ |---|---|---|---|
649
+ | 4.0.5 (arm64-darwin25) | full suite | full suite | local |
650
+ | 3.4.11 (`ruby:3.4-slim`, aarch64-linux) | full suite | full suite | Docker |
651
+ | 3.3.12 (`ruby:3.3-slim`, aarch64-linux) | full suite | full suite | Docker |
652
+
653
+ See [docs/testing.md](docs/testing.md) for the exact versions, commands and results. Other
654
+ engines raise `Adornment::UnsupportedPlatformError` on require.
655
+
656
+ ## Development
657
+
658
+ ```bash
659
+ bundle install
660
+ bundle exec rake # specs
661
+ BUNDLE_WITH=tooling bundle install # once, for RuboCop (an optional Gemfile group)
662
+ BUNDLE_WITH=tooling bundle exec rubocop
663
+ ruby benchmarks/compare.rb # speed against the recorded baseline (RECORD=1 to re-record)
664
+ ruby benchmarks/memory_compare.rb # retained memory against the recorded baseline
665
+ ```
666
+
667
+ ## License
668
+
669
+ MIT