assistant 0.0.2 → 1.0.0.rc1

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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/.editorconfig +13 -0
  3. data/.github/PULL_REQUEST_TEMPLATE.md +39 -0
  4. data/.github/dependabot.yml +4 -0
  5. data/.github/workflows/ci.yml +140 -0
  6. data/.github/workflows/docs.yml +64 -0
  7. data/.github/workflows/release.yml +46 -0
  8. data/.gitignore +5 -1
  9. data/.markdownlint.json +6 -0
  10. data/.opencode/.gitignore +4 -0
  11. data/.opencode/opencode.json +13 -0
  12. data/.opencode/skills/create-pr/SKILL.md +138 -0
  13. data/.opencode/skills/ruby-services/SKILL.md +81 -0
  14. data/.rubocop.yml +40 -148
  15. data/.ruby-version +1 -1
  16. data/.yardopts +17 -0
  17. data/CHANGELOG.md +434 -0
  18. data/CONTRIBUTING.md +131 -0
  19. data/Gemfile +10 -0
  20. data/Gemfile.lock +264 -94
  21. data/README.md +125 -16
  22. data/Rakefile +53 -3
  23. data/SECURITY.md +50 -0
  24. data/Steepfile +49 -0
  25. data/_config.yml +87 -0
  26. data/assistant.gemspec +33 -20
  27. data/docs/api-reference.md +264 -0
  28. data/docs/changelog.md +26 -0
  29. data/docs/deprecations.md +86 -0
  30. data/docs/examples/cli-handler.md +17 -0
  31. data/docs/examples/composing-services.md +17 -0
  32. data/docs/examples/execute-callbacks.md +17 -0
  33. data/docs/examples/index.md +29 -0
  34. data/docs/examples/instrumentation-notifier.md +17 -0
  35. data/docs/examples/rails-service.md +17 -0
  36. data/docs/examples/rbs-generator.md +17 -0
  37. data/docs/examples/sidekiq-worker.md +17 -0
  38. data/docs/getting-started.md +136 -0
  39. data/docs/guides/composing-services.md +222 -0
  40. data/docs/guides/index.md +25 -0
  41. data/docs/guides/inputs.md +333 -0
  42. data/docs/guides/logging-and-results.md +202 -0
  43. data/docs/guides/rbs-and-types.md +16 -0
  44. data/docs/guides/validation.md +180 -0
  45. data/docs/index.md +69 -0
  46. data/docs/roadmap.md +33 -0
  47. data/exe/assistant-rbs +7 -0
  48. data/lib/assistant/execute_callbacks.rb +103 -0
  49. data/lib/assistant/execute_callbacks.rbs +30 -0
  50. data/lib/assistant/input_builder/accessors.rb +36 -0
  51. data/lib/assistant/input_builder/accessors.rbs +10 -0
  52. data/lib/assistant/input_builder/default_option.rb +41 -0
  53. data/lib/assistant/input_builder/default_option.rbs +11 -0
  54. data/lib/assistant/input_builder/dsl.rb +37 -0
  55. data/lib/assistant/input_builder/dsl.rbs +12 -0
  56. data/lib/assistant/input_builder/optional_option.rb +45 -0
  57. data/lib/assistant/input_builder/optional_option.rbs +10 -0
  58. data/lib/assistant/input_builder/registry.rb +27 -0
  59. data/lib/assistant/input_builder/registry.rbs +13 -0
  60. data/lib/assistant/input_builder/require_validator.rb +104 -0
  61. data/lib/assistant/input_builder/require_validator.rbs +24 -0
  62. data/lib/assistant/input_builder/type_validator.rb +47 -0
  63. data/lib/assistant/input_builder/type_validator.rbs +18 -0
  64. data/lib/assistant/input_builder.rb +28 -0
  65. data/lib/assistant/input_builder.rbs +15 -0
  66. data/lib/assistant/log_item.rb +75 -17
  67. data/lib/assistant/log_item.rbs +40 -0
  68. data/lib/assistant/log_list.rb +44 -12
  69. data/lib/assistant/log_list.rbs +48 -0
  70. data/lib/assistant/rbs_generator/cli.rb +109 -0
  71. data/lib/assistant/rbs_generator/cli.rbs +24 -0
  72. data/lib/assistant/rbs_generator/renderer.rb +67 -0
  73. data/lib/assistant/rbs_generator/renderer.rbs +11 -0
  74. data/lib/assistant/rbs_generator/writer.rb +65 -0
  75. data/lib/assistant/rbs_generator/writer.rbs +24 -0
  76. data/lib/assistant/rbs_generator.rb +38 -0
  77. data/lib/assistant/rbs_generator.rbs +5 -0
  78. data/lib/assistant/refinements/string_blankness.rb +14 -0
  79. data/lib/assistant/refinements/string_blankness.rbs +6 -0
  80. data/lib/assistant/service.rb +328 -8
  81. data/lib/assistant/service.rbs +86 -0
  82. data/lib/assistant/version.rb +5 -1
  83. data/lib/assistant/version.rbs +5 -0
  84. data/lib/assistant.rb +53 -4
  85. data/lib/assistant.rbs +25 -0
  86. data/mise.toml +6 -0
  87. data/sig/examples/greeter.rbs +14 -0
  88. metadata +128 -112
  89. data/.circleci/config.yml +0 -45
  90. data/.fasterer.yml +0 -19
  91. data/.rspec +0 -3
  92. data/.rubocop_todo.yml +0 -0
@@ -0,0 +1,180 @@
1
+ ---
2
+ title: Validation
3
+ parent: Guides
4
+ nav_order: 2
5
+ ---
6
+
7
+ <!-- markdownlint-disable MD013 MD024 -->
8
+ # Validation
9
+
10
+ > **TL;DR** — Declarative `input` checks (`type:`, `required:`,
11
+ > `if:`, etc.) run automatically before `#execute`. For everything
12
+ > else, override `#validate` and call `log_item_error(...)` to
13
+ > short-circuit, or `log_item_warning(...)` to flag a recoverable
14
+ > issue. `LogItem.new` raises `ArgumentError` for invalid attributes
15
+ > in 1.0 — use the helpers, not `LogItem.new` directly.
16
+
17
+ This guide covers the validation surface beyond the declarative
18
+ options on [`input`](./inputs.md): the `validate` hook, the
19
+ warning-vs-error decision, the strict `LogItem` constructor, and
20
+ conditional patterns.
21
+
22
+ ## What runs automatically
23
+
24
+ For every `Service.input :name, type: T, required: ..., if: ...`,
25
+ the gem generates and runs:
26
+
27
+ - `#valid_type_name?` — type check (or multi-type with M3 union).
28
+ - `#valid_required_name?` — presence check, when `required: true`.
29
+ - `#valid_required_conditional_name?` — presence + predicate, when
30
+ `required: true` *and* `if:` are both supplied.
31
+
32
+ `#run` calls every `valid_required_*?`, `valid_required_conditional_*?`,
33
+ and `valid_type_*?` method that matches by naming convention before
34
+ calling your `#validate`. Failures are logged as error-level
35
+ `LogItem`s and short-circuit `#execute`.
36
+
37
+ ## Adding your own checks with `#validate`
38
+
39
+ Override `#validate` to log domain-specific errors:
40
+
41
+ ```ruby
42
+ class CreateUser < Assistant::Service
43
+ input :email, type: String, required: true
44
+
45
+ def validate
46
+ return if email.include?('@')
47
+
48
+ log_item_error(source: :validate, detail: :email, message: 'must contain @')
49
+ end
50
+
51
+ def execute
52
+ { email: }
53
+ end
54
+ end
55
+
56
+ CreateUser.run(email: 'a@b.com').fetch(:status) # => :ok
57
+ CreateUser.run(email: 'oops').fetch(:status) # => :with_errors
58
+ ```
59
+
60
+ `#validate` runs **after** the declarative checks. If a declarative
61
+ check already added an error, your `#validate` still runs (it has the
62
+ chance to surface additional context), but `#execute` is skipped.
63
+
64
+ ## Warning vs. error: how to choose
65
+
66
+ | Level | Helper | Effect |
67
+ |-----------|-------------------------|---------------------------------------------------------------------------------|
68
+ | `:info` | `log_item_info(...)` | Recorded on `#logs`; does not affect `#status`. |
69
+ | `:warning`| `log_item_warning(...)` | Flips `#status` from `:ok` to `:with_warnings`; `#execute` still runs. |
70
+ | `:error` | `log_item_error(...)` | Flips `#status` to `:with_errors`; `#execute` is **skipped**, `#result` is nil. |
71
+
72
+ Rule of thumb:
73
+
74
+ - **Use an error** when continuing would produce an invalid or
75
+ misleading result (`#execute` would have to handle the bad state).
76
+ - **Use a warning** when the result is still meaningful but the
77
+ caller should know something is off (a missing optional input, an
78
+ in-progress migration shape, a deprecated value).
79
+
80
+ A worked example:
81
+
82
+ ```ruby
83
+ class CreateUser < Assistant::Service
84
+ input :email, type: String, required: true
85
+ input :age, type: Integer, allow_nil: true, default: nil
86
+
87
+ def validate
88
+ log_item_error(source: :validate, detail: :email, message: 'invalid email') unless email.include?('@')
89
+ log_item_warning(source: :validate, detail: :age, message: 'age missing') if age.nil?
90
+ end
91
+
92
+ def execute
93
+ { email:, age: }
94
+ end
95
+ end
96
+
97
+ CreateUser.run(email: 'a@b.com').fetch(:status)
98
+ # => :with_warnings — age is missing, but we still build the result
99
+
100
+ CreateUser.run(email: 'oops').fetch(:status)
101
+ # => :with_errors — execute is skipped
102
+ ```
103
+
104
+ ## Conditional requirements
105
+
106
+ When a presence check should fire only sometimes, combine
107
+ `required: true` with `if:`:
108
+
109
+ ```ruby
110
+ class UpdateUser < Assistant::Service
111
+ input :role, type: Symbol, default: :member
112
+ input :reason, type: String, required: true, if: ->(_value) { true }
113
+
114
+ def execute
115
+ { role:, reason: }
116
+ end
117
+ end
118
+
119
+ UpdateUser.run(role: :member).fetch(:status)
120
+ # => :with_errors — predicate is truthy, so :reason is required
121
+
122
+ UpdateUser.run(role: :member, reason: 'audit cleanup').fetch(:status)
123
+ # => :ok
124
+ ```
125
+
126
+ The `if:` predicate is called with the *input's own value*. The
127
+ validator requires the input to be present **and** the predicate to
128
+ be truthy — so the canonical use is "I need this to be present
129
+ *when* some other condition holds". See
130
+ [`inputs.md`](./inputs.md#if-conditional-requirement) for the
131
+ inverse pattern.
132
+
133
+ ## `LogItem.new` raises in 1.0 (M10)
134
+
135
+ Constructing a `LogItem` directly with invalid attributes now raises
136
+ `ArgumentError`. The `#valid?` family is kept for introspection but
137
+ always returns `true` after a successful `new`:
138
+
139
+ ```ruby
140
+ Assistant::LogItem.new(level: :info, source: :a, detail: :b, message: 'ok').valid?
141
+ # => true
142
+
143
+ begin
144
+ Assistant::LogItem.new(level: :info, source: :a, detail: :b, message: '')
145
+ rescue ArgumentError => e
146
+ e.message # => "invalid LogItem: message must be present"
147
+ end
148
+ ```
149
+
150
+ Inside a `Service`, you almost never need `LogItem.new` directly:
151
+ `log_item_info(...)`, `log_item_warning(...)`, `log_item_error(...)`,
152
+ and `add_log(level:, source:, detail:, message:)` build the item and
153
+ append it to `#logs` for you. See
154
+ [`logging-and-results.md`](./logging-and-results.md) for the full
155
+ catalogue.
156
+
157
+ ## Common pitfalls
158
+
159
+ - **Returning `false` from `#validate` to signal failure.** The hook's
160
+ return value is ignored. The only way to fail is to log an
161
+ error-level `LogItem`.
162
+ - **Calling `raise` inside `#validate` or `#execute`.** Don't —
163
+ `assistant` is soft-fail. Convert expected failures into log items.
164
+ Unexpected exceptions propagate (the gem catches exceptions only
165
+ in `before_execute` / `around_execute` / `after_execute` hooks).
166
+ - **Building `LogItem.new(...)` and pushing it onto `#logs`.** Use the
167
+ helpers; they apply the same M10 strict construction and keep your
168
+ call sites readable.
169
+ - **Forgetting that `#validate` runs even when a declarative check
170
+ already failed.** Either guard `#validate` with `return if
171
+ errors.any?`, or design it to add complementary errors.
172
+
173
+ ## See also
174
+
175
+ - [Inputs guide](./inputs.md) — `required:`, `if:`, multi-type, the
176
+ generated `valid_*` predicates.
177
+ - [Logging and results](./logging-and-results.md) — the helpers, the
178
+ full result hash, log filtering.
179
+ - [API reference: LogItem](../api-reference.md#assistantlogitem).
180
+ - [API reference: LogList](../api-reference.md#assistantloglist).
data/docs/index.md ADDED
@@ -0,0 +1,69 @@
1
+ ---
2
+ title: Home
3
+ layout: home
4
+ nav_order: 0
5
+ permalink: /
6
+ ---
7
+
8
+ # Assistant
9
+
10
+ **Tiny, dependency-free soft-fail service objects for Ruby.**
11
+
12
+ [![Gem Version](https://badge.fury.io/rb/assistant.svg)](https://rubygems.org/gems/assistant)
13
+ [![CI](https://github.com/ramongr/assistant/actions/workflows/ci.yml/badge.svg)](https://github.com/ramongr/assistant/actions/workflows/ci.yml)
14
+
15
+ Assistant lets you write service objects that **never raise for expected
16
+ failures**. A service declares its inputs, validates them, runs its body, and
17
+ returns a uniform result hash that always carries either a value plus
18
+ warnings or a list of errors. Ships with RBS signatures, a 1.0-frozen public
19
+ API, and zero runtime gem dependencies.
20
+
21
+ ## Install
22
+
23
+ ```ruby
24
+ # Gemfile
25
+ gem 'assistant', '~> 1.0'
26
+ ```
27
+
28
+ ```sh
29
+ bundle install
30
+ ```
31
+
32
+ Ruby 3.4 or newer is required.
33
+
34
+ ## The 60-second example
35
+
36
+ ```ruby
37
+ require 'assistant'
38
+
39
+ class CreateUser < Assistant::Service
40
+ input :email, type: String, required: true
41
+ input :name, type: String, default: 'Anonymous'
42
+
43
+ def execute
44
+ log_item_info(source: :create_user, detail: :persisted, message: "saved #{email}")
45
+ User.create!(email: email, name: name)
46
+ end
47
+ end
48
+
49
+ case CreateUser.run(email: 'me@example.com')
50
+ in { result:, status: :ok }
51
+ result
52
+ in { errors:, status: :with_errors }
53
+ errors.map(&:item)
54
+ end
55
+ ```
56
+
57
+ ## Where to next
58
+
59
+ - **[Getting started](getting-started.md)** — walk through your first
60
+ service end-to-end.
61
+ - **[Guides](guides/inputs.md)** — DSL deep-dives, one page per concern.
62
+ - **[API reference](api-reference.md)** — every Frozen symbol, deep-link
63
+ friendly.
64
+ - **[Examples](examples/index.md)** — runnable patterns (Rails, CLI,
65
+ Sidekiq, composition, callbacks, instrumentation, RBS).
66
+ - **[Roadmap](roadmap.md)** — what's planned, what shipped.
67
+ - **[Changelog](changelog.md)** — full release history.
68
+
69
+ Source on [GitHub](https://github.com/ramongr/assistant).
data/docs/roadmap.md ADDED
@@ -0,0 +1,33 @@
1
+ ---
2
+ title: Roadmap
3
+ nav_order: 6
4
+ ---
5
+
6
+ # Roadmap
7
+
8
+ The full 1.0 plan lives in
9
+ [`docs/v1/`](https://github.com/ramongr/assistant/tree/main/docs/v1) in
10
+ the repository. Each file below is a focused planning document; the
11
+ index ties them together.
12
+
13
+ | Doc | What it covers |
14
+ | --- | --- |
15
+ | [`README.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/README.md) | Plan index and reading order. |
16
+ | [`00-overview.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/00-overview.md) | 1.0 goals, non-goals, acceptance criteria. |
17
+ | [`01-api-surface.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/01-api-surface.md) | Frozen vs Experimental symbols, stability labels. |
18
+ | [`02-features.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/02-features.md) | M1–M13 milestones and the promoted M-S* set. |
19
+ | [`03-documentation.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/03-documentation.md) | D1–D5 documentation deliverables. |
20
+ | [`04-release-checklist.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/04-release-checklist.md) | Pre-release, RC, release, and post-release steps. |
21
+ | [`05-quality-and-tooling.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/05-quality-and-tooling.md) | SimpleCov, RuboCop, Steep, CI matrix. |
22
+ | [`06-migration-0x-to-1.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/06-migration-0x-to-1.md) | The three mechanical rewrites required to upgrade. |
23
+ | [`07-risks-and-open-questions.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/07-risks-and-open-questions.md) | Known constraints (e.g. R1 RBS limitation) and resolutions. |
24
+ | [`08-github-pages.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/08-github-pages.md) | The plan for **this site** (parallel deliverable, not a 1.0 gate). |
25
+
26
+ ## Current snapshot
27
+
28
+ - 1.0 release plumbing is in flight — the gem is at
29
+ [`1.0.0.rc1`](changelog.md) and the migration recipe is finalised.
30
+ - Every "Must" milestone (M1–M13) and every promoted "Should"
31
+ (M-S1–M-S4) has shipped to `main`.
32
+ - The Jekyll site you're reading now is P2 of the GitHub Pages plan;
33
+ later phases land the remaining guide / example content.
data/exe/assistant-rbs ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'assistant'
5
+ require 'assistant/rbs_generator'
6
+
7
+ exit Assistant::RbsGenerator::Cli.run(ARGV)
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Class-level DSL for registering `before_execute` / `after_execute` /
4
+ # `around_execute` hooks on `Assistant::Service` subclasses (M-S1).
5
+ #
6
+ # Mixed into `Service.singleton_class` so the DSL is available at class
7
+ # definition time. Hooks are stored as `UnboundMethod`s on private
8
+ # anonymous Modules so we can bind `self` to the service instance and
9
+ # still pass a block (the continuation) into `around_execute` hooks.
10
+ #
11
+ # Hooks are inherited at subclass-definition time: a subclass receives
12
+ # a duplicate of each registered hook array. Adding more hooks to the
13
+ # subclass does not affect the parent, and vice-versa.
14
+ #
15
+ # See docs/v1/02-features.md M-S1 and docs/v1/01-api-surface.md.
16
+ module Assistant::ExecuteCallbacks
17
+ # The exhaustive set of hook types this module manages.
18
+ # @return [Array<Symbol>]
19
+ HOOK_TYPES = %i[before_execute after_execute around_execute].freeze
20
+
21
+ # @return [Array<UnboundMethod>] hooks registered via {#before_execute}, in declaration order
22
+ def before_execute_hooks
23
+ @before_execute_hooks ||= []
24
+ end
25
+
26
+ # @return [Array<UnboundMethod>] hooks registered via {#after_execute}, in declaration order
27
+ def after_execute_hooks
28
+ @after_execute_hooks ||= []
29
+ end
30
+
31
+ # @return [Array<UnboundMethod>] hooks registered via {#around_execute}, in declaration order
32
+ def around_execute_hooks
33
+ @around_execute_hooks ||= []
34
+ end
35
+
36
+ # Register a block to run after validation and before `#execute`.
37
+ # `self` inside the block is the service instance.
38
+ #
39
+ # @yield runs in the context of the service instance after validation, before `#execute`
40
+ # @raise [ArgumentError] when no block is given
41
+ # @return [Array<UnboundMethod>] the updated {#before_execute_hooks} chain
42
+ def before_execute(&block)
43
+ raise ArgumentError, 'before_execute requires a block' unless block
44
+
45
+ before_execute_hooks << build_hook(block)
46
+ end
47
+
48
+ # Register a block to run after `#execute` returns. `self` inside the
49
+ # block is the service instance; the execute result is passed as the
50
+ # single positional argument.
51
+ #
52
+ # @yieldparam execute_result [Object] return value of `#execute`
53
+ # @raise [ArgumentError] when no block is given
54
+ # @return [Array<UnboundMethod>] the updated {#after_execute_hooks} chain
55
+ def after_execute(&block)
56
+ raise ArgumentError, 'after_execute requires a block' unless block
57
+
58
+ after_execute_hooks << build_hook(block)
59
+ end
60
+
61
+ # Register a block that wraps `#execute`. `self` inside the block is
62
+ # the service instance; the `&blk` block argument yields to the
63
+ # inner stack (the next around hook, or `#execute` for the innermost
64
+ # layer). Declaration order wraps: the first declared hook is the
65
+ # outermost layer.
66
+ #
67
+ # @yield runs in the context of the service instance, wrapping the inner stack
68
+ # @yieldparam blk [Proc] the continuation; call `yield` (or `blk.call`) to invoke the inner layer
69
+ # @raise [ArgumentError] when no block is given
70
+ # @return [Array<UnboundMethod>] the updated {#around_execute_hooks} chain
71
+ def around_execute(&block)
72
+ raise ArgumentError, 'around_execute requires a block' unless block
73
+
74
+ around_execute_hooks << build_hook(block)
75
+ end
76
+
77
+ # Snapshot parent hooks into the subclass at definition time. The
78
+ # snapshot is a `dup` so the subclass owns its own array and further
79
+ # additions on either side never bleed across the hierarchy.
80
+ #
81
+ # @param subclass [Class] freshly defined subclass
82
+ # @return [void]
83
+ def inherited(subclass)
84
+ super
85
+ subclass.instance_variable_set(:@before_execute_hooks, before_execute_hooks.dup)
86
+ subclass.instance_variable_set(:@after_execute_hooks, after_execute_hooks.dup)
87
+ subclass.instance_variable_set(:@around_execute_hooks, around_execute_hooks.dup)
88
+ end
89
+
90
+ private
91
+
92
+ # Wrap the user's block in an anonymous Module so we can convert it
93
+ # to an `UnboundMethod`. `um.bind_call(service, *args, &cont)` then
94
+ # runs the user's block with `self` == the service instance AND
95
+ # passes a block argument through to the block's `&blk` parameter,
96
+ # which is essential for `around_execute` continuations and cannot
97
+ # be expressed with `instance_exec` alone.
98
+ def build_hook(block)
99
+ mod = Module.new
100
+ mod.send(:define_method, :__assistant_execute_hook__, &block)
101
+ mod.instance_method(:__assistant_execute_hook__)
102
+ end
103
+ end
@@ -0,0 +1,30 @@
1
+ # Type signatures for `lib/assistant/execute_callbacks.rb`. See
2
+ # docs/v1/01-api-surface.md for the frozen 1.0 surface. The block types
3
+ # for `before_execute` / `after_execute` / `around_execute` are loosely
4
+ # typed because the user's block can take a `&blk` continuation, varied
5
+ # `result` shapes, or no arguments at all — RBS cannot model the
6
+ # `instance_exec`-like rebinding precisely.
7
+
8
+ module Assistant
9
+ module ExecuteCallbacks
10
+ HOOK_TYPES: Array[Symbol]
11
+
12
+ @before_execute_hooks: Array[UnboundMethod]
13
+ @after_execute_hooks: Array[UnboundMethod]
14
+ @around_execute_hooks: Array[UnboundMethod]
15
+
16
+ def before_execute_hooks: () -> Array[UnboundMethod]
17
+ def after_execute_hooks: () -> Array[UnboundMethod]
18
+ def around_execute_hooks: () -> Array[UnboundMethod]
19
+
20
+ def before_execute: () { (*untyped) -> untyped } -> Array[UnboundMethod]
21
+ def after_execute: () { (*untyped) -> untyped } -> Array[UnboundMethod]
22
+ def around_execute: () { (*untyped) -> untyped } -> Array[UnboundMethod]
23
+
24
+ def inherited: (Class subclass) -> void
25
+
26
+ private
27
+
28
+ def build_hook: (Proc block) -> UnboundMethod
29
+ end
30
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'assistant/refinements/string_blankness'
4
+
5
+ # Generators for the per-input reader and `?`-checker instance methods.
6
+ # The lexical refinement `Assistant::Refinements::StringBlankness` is
7
+ # activated here only (narrower than the pre-M13 module-wide `using`).
8
+ module Assistant::InputBuilder::Accessors
9
+ using Assistant::Refinements::StringBlankness
10
+
11
+ # Define `#name` reader on the host class. Returns the raw value
12
+ # stored under `@inputs[name]`.
13
+ #
14
+ # @param name [Symbol] input name
15
+ # @return [void]
16
+ def input_getter_meth(name:)
17
+ define_method(name) do
18
+ @inputs[name]
19
+ end
20
+ end
21
+
22
+ # Define `#name?` predicate on the host class. Treats `nil`, `false`,
23
+ # whitespace-only strings, and `#empty?` collections as missing.
24
+ #
25
+ # @param name [Symbol] input name
26
+ # @return [void]
27
+ def input_checker_meth(name:)
28
+ define_method("#{name}?") do
29
+ val = @inputs[name]
30
+ return false if val.nil? || val == false
31
+ return !val.whitespace? if val.is_a?(String)
32
+
33
+ val.respond_to?(:empty?) ? !val.empty? : true
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,10 @@
1
+ # Type signatures for `lib/assistant/input_builder/accessors.rb`. Each
2
+ # helper defines a per-input instance method on the host Service
3
+ # subclass via `define_method`; from RBS's perspective the helpers
4
+ # return whatever `define_method` returns (a Symbol).
5
+
6
+ module Assistant::InputBuilder::Accessors
7
+ def input_getter_meth: (name: Symbol) -> Symbol
8
+
9
+ def input_checker_meth: (name: Symbol) -> Symbol
10
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # M1: class-time gate for the `default:` option — fail fast on illegal
4
+ # providers, warn on shared mutable literals. Pure side-effects, no
5
+ # interaction with the per-class definitions registry.
6
+ module Assistant::InputBuilder::DefaultOption
7
+ # Run the class-time gate for an input's `default:` provider:
8
+ # reject illegal providers, warn on shared mutable literals.
9
+ #
10
+ # @param name [Symbol] input name
11
+ # @param default [Object, Proc] the literal value or zero-arity Proc
12
+ # @return [void]
13
+ # @raise [ArgumentError] when `default` is callable but not a zero-arity Proc
14
+ def process_default_option(name:, default:)
15
+ validate_default!(name:, default:)
16
+ warn_on_mutable_default(name:, default:)
17
+ end
18
+
19
+ # M1: a default: provider must be either a literal value or a
20
+ # zero-arity Proc/Lambda. Anything else that responds to #call (a
21
+ # Method object, a custom callable) is rejected at class-definition
22
+ # time.
23
+ def validate_default!(name:, default:)
24
+ if default.is_a?(Proc) && !default.arity.zero? && default.arity != -1
25
+ raise ArgumentError, "default: for input :#{name} must be a zero-arity Proc, got arity #{default.arity}"
26
+ elsif !default.is_a?(Proc) && default.respond_to?(:call)
27
+ raise ArgumentError, "default: for input :#{name} must be a literal or a zero-arity Proc, not a #{default.class}"
28
+ end
29
+ end
30
+
31
+ # M1: warn when a mutable literal default (unfrozen Array/Hash) is
32
+ # used — such a default is shared across every instance of the
33
+ # Service subclass and almost never what the author wants. Frozen
34
+ # literals and Procs are safe and pass silently.
35
+ def warn_on_mutable_default(name:, default:)
36
+ return unless (default.is_a?(Array) || default.is_a?(Hash)) && !default.frozen?
37
+
38
+ Kernel.warn("assistant: input :#{name} has a mutable #{default.class} default; " \
39
+ 'use `default: -> { ... }` to avoid sharing state across instances')
40
+ end
41
+ end
@@ -0,0 +1,11 @@
1
+ # Type signatures for `lib/assistant/input_builder/default_option.rb`
2
+ # (M1). Raises `ArgumentError` at class-definition time for illegal
3
+ # default providers; warns on shared mutable literals.
4
+
5
+ module Assistant::InputBuilder::DefaultOption
6
+ def process_default_option: (name: Symbol, default: untyped) -> void
7
+
8
+ def validate_default!: (name: Symbol, default: untyped) -> void
9
+
10
+ def warn_on_mutable_default: (name: Symbol, default: untyped) -> void
11
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Public DSL surface: declarative `#input`/`#inputs` that register an
4
+ # input definition and generate the per-input accessor + validator
5
+ # instance methods on the host Service subclass. Calls into every
6
+ # other InputBuilder submodule.
7
+ #
8
+ # These two methods deliberately keep their leading positional `name`
9
+ # parameter even though every other M12 helper is keyword-only --
10
+ # `input :foo, type: String` reads better as a class-body declaration
11
+ # than `input name: :foo, type: String`. The internal helpers we call
12
+ # below are still keyword-only; we just map the positional `attr_name`
13
+ # /`attr_names` here to `name:` / `names:` on the way down.
14
+ module Assistant::InputBuilder::Dsl
15
+ # Lists all inputs that have the same type and options.
16
+ def inputs(attr_names, type:, **)
17
+ attr_names.each do |attr_name|
18
+ input(attr_name, type:, **)
19
+ end
20
+ end
21
+
22
+ # Individual input with a specific type or options.
23
+ def input(attr_name, type:, **options)
24
+ process_default_option(name: attr_name, default: options[:default]) if options.key?(:default)
25
+ options = process_optional_option(name: attr_name, options:) if options.key?(:optional)
26
+ register_input_definition(name: attr_name, type:, options:)
27
+
28
+ # Base Methods
29
+ input_getter_meth(name: attr_name)
30
+ input_checker_meth(name: attr_name)
31
+
32
+ # Input type validation method, simple and conditional requirement validation methods
33
+ input_type_validator_meth(name: attr_name, type:, **options)
34
+ input_require_validator_meth(name: attr_name, **options) if options[:required] == true
35
+ input_require_conditional_meth(name: attr_name, **options) if options[:required] == true && options[:if]
36
+ end
37
+ end
@@ -0,0 +1,12 @@
1
+ # Type signatures for `lib/assistant/input_builder/dsl.rb`. The public
2
+ # `#input`/`#inputs` DSL surface; see docs/v1/01-api-surface.md.
3
+ #
4
+ # Note: `#input` and `#inputs` deliberately keep their leading
5
+ # positional `attr_name`/`attr_names` parameter; every other M12 helper
6
+ # is keyword-only.
7
+
8
+ module Assistant::InputBuilder::Dsl
9
+ def inputs: (Array[Symbol] attr_names, type: untyped, **untyped) -> Array[Symbol]
10
+
11
+ def input: (Symbol attr_name, type: untyped, **untyped) -> untyped
12
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ # M7: explicit `optional:` flag handling. Validates the value and
4
+ # returns the canonical option hash (with `:required` derived from
5
+ # `optional: false`). Mirrors `DefaultOption`'s shape so the `#input`
6
+ # call site stays one line per option family.
7
+ module Assistant::InputBuilder::OptionalOption
8
+ # Validate the `optional:` keyword for an input and return the
9
+ # canonical option hash. `optional: false` is translated into
10
+ # `required: true` so downstream validators see a single flag.
11
+ #
12
+ # @param name [Symbol] input name
13
+ # @param options [Hash] options hash from the `#input` call
14
+ # @return [Hash] the (possibly translated) options hash
15
+ # @raise [ArgumentError] when `optional:` is non-boolean or contradicts `required: true`
16
+ def process_optional_option(name:, options:)
17
+ validate_optional!(name:, options:)
18
+ apply_optional_option(options)
19
+ end
20
+
21
+ # M7: `optional:` must be a boolean. `optional: true` together with
22
+ # `required: true` is a contradiction. Both rules raise
23
+ # `ArgumentError` at class-definition time, before any method is
24
+ # generated.
25
+ def validate_optional!(name:, options:)
26
+ optional = options[:optional]
27
+ unless [true, false].include?(optional)
28
+ raise ArgumentError, "optional: for input :#{name} must be true or false, got #{optional.inspect}"
29
+ end
30
+ return unless optional == true && options[:required] == true
31
+
32
+ raise ArgumentError, "input :#{name} cannot be both required: true and optional: true"
33
+ end
34
+
35
+ # M7: pure translation of the validated `optional:` value into the
36
+ # canonical `required:` flag used by downstream validator helpers.
37
+ # `optional: false` -> `required: true`; `optional: true` is left
38
+ # alone (no `valid_require_<name>?` is generated, matching the
39
+ # default). The original `:optional` key is retained in
40
+ # `input_definitions` for introspection. Non-mutating: callers
41
+ # receive a new hash when a translation is applied.
42
+ def apply_optional_option(options)
43
+ options[:optional] == false ? options.merge(required: true) : options
44
+ end
45
+ end
@@ -0,0 +1,10 @@
1
+ # Type signatures for `lib/assistant/input_builder/optional_option.rb`
2
+ # (M7). Validates the `optional:` flag and normalises the option hash.
3
+
4
+ module Assistant::InputBuilder::OptionalOption
5
+ def process_optional_option: (name: Symbol, options: Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
6
+
7
+ def validate_optional!: (name: Symbol, options: Hash[Symbol, untyped]) -> void
8
+
9
+ def apply_optional_option: (Hash[Symbol, untyped] options) -> Hash[Symbol, untyped]
10
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Per-class registry of input definitions. Each Service subclass gets its
4
+ # own hash keyed by attribute name with the original keyword options
5
+ # frozen for introspection (used by Service#initialize for M1 defaulting
6
+ # and by the M11 RBS generator).
7
+ module Assistant::InputBuilder::Registry
8
+ # Per-class hash of input definitions, keyed by attribute name.
9
+ # Values are frozen `{ type:, **options }` hashes. Read by
10
+ # `Service#initialize` (M1 defaulting), by `Service#input_snapshot`
11
+ # (M-S4), and by the M11 RBS generator.
12
+ #
13
+ # @return [Hash{Symbol => Hash}]
14
+ def input_definitions
15
+ @input_definitions ||= {}
16
+ end
17
+
18
+ # Register or replace an input definition.
19
+ #
20
+ # @param name [Symbol] input name
21
+ # @param type [Class, Array<Class>] declared type(s)
22
+ # @param options [Hash] remaining `#input` keyword options
23
+ # @return [Hash] the frozen definition entry just stored
24
+ def register_input_definition(name:, type:, options:)
25
+ input_definitions[name] = { type:, **options }.freeze
26
+ end
27
+ end