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,13 @@
1
+ # Type signatures for `lib/assistant/input_builder/registry.rb`.
2
+
3
+ module Assistant::InputBuilder::Registry
4
+ @input_definitions: Hash[Symbol, Hash[Symbol, untyped]]
5
+
6
+ def input_definitions: () -> Hash[Symbol, Hash[Symbol, untyped]]
7
+
8
+ def register_input_definition: (
9
+ name: Symbol,
10
+ type: untyped,
11
+ options: Hash[Symbol, untyped]
12
+ ) -> Hash[Symbol, untyped]
13
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generators for the per-input requirement validators. Canonical names
4
+ # (M9):
5
+ #
6
+ # #valid_required_<name>? # for `required: true`
7
+ # #valid_required_conditional_<name>? # for `required: true, if: ...`
8
+ #
9
+ # Pre-M9 names (`valid_require_*?`, `valid_require_conditional_*?`) are
10
+ # kept as deprecated aliases that delegate to the canonical method and
11
+ # emit a one-shot `Kernel.warn` per call site. They are scheduled for
12
+ # removal in `assistant 2.0`.
13
+ module Assistant::InputBuilder::RequireValidator
14
+ # Guard so each deprecated alias warns at most once per textual call
15
+ # site (file + lineno), regardless of how many times it is invoked or
16
+ # how many input names are involved. Internal; tests reset it via
17
+ # `.send(:__reset_deprecation_warnings__)`.
18
+ DEPRECATION_WARNED = Set.new
19
+ private_constant :DEPRECATION_WARNED
20
+
21
+ # Emit the M9 one-shot deprecation warning for a `valid_require_*?`
22
+ # call. Deduped per `[canonical, caller path, caller lineno]` so the
23
+ # same call site never warns twice in one process.
24
+ #
25
+ # @param deprecated_name [Symbol] e.g. `:valid_require_name?`
26
+ # @param canonical_name [Symbol] e.g. `:valid_required_name?`
27
+ # @param caller_location [Thread::Backtrace::Location]
28
+ # @return [void]
29
+ def self.warn_deprecated(deprecated_name, canonical_name, caller_location)
30
+ key = [canonical_name, caller_location.path, caller_location.lineno]
31
+ return if DEPRECATION_WARNED.include?(key)
32
+
33
+ DEPRECATION_WARNED << key
34
+ Kernel.warn("assistant: `##{deprecated_name}` is deprecated; use `##{canonical_name}` (removed in assistant 2.0)")
35
+ end
36
+
37
+ # Test-only hook: clears the per-call-site dedupe set so a single
38
+ # test process can exercise multiple "first warn" scenarios.
39
+ #
40
+ # @return [void]
41
+ def self.__reset_deprecation_warnings__
42
+ DEPRECATION_WARNED.clear
43
+ end
44
+
45
+ # Define `#valid_required_<name>?` on the host class plus the
46
+ # deprecated `#valid_require_<name>?` alias.
47
+ #
48
+ # @param name [Symbol] input name
49
+ # @return [void]
50
+ def input_require_validator_meth(name:, **)
51
+ canonical = :"valid_required_#{name}?"
52
+ define_required_validator(canonical:, name:, **)
53
+ define_deprecated_alias(:"valid_require_#{name}?", canonical)
54
+ end
55
+
56
+ # Define `#valid_required_conditional_<name>?` on the host class
57
+ # plus the deprecated `#valid_require_conditional_<name>?` alias.
58
+ #
59
+ # @param name [Symbol] input name
60
+ # @return [void]
61
+ def input_require_conditional_meth(name:, **)
62
+ canonical = :"valid_required_conditional_#{name}?"
63
+ define_required_conditional_validator(canonical:, name:, **)
64
+ define_deprecated_alias(:"valid_require_conditional_#{name}?", canonical)
65
+ end
66
+
67
+ private
68
+
69
+ def define_required_validator(canonical:, name:, **options)
70
+ allow_nil = options.fetch(:allow_nil, false) == true
71
+
72
+ define_method(canonical) do |log = true|
73
+ # M2: explicit nil counts as "supplied" when allow_nil: true is set.
74
+ return true if allow_nil && @inputs.key?(name)
75
+ return true if options[:required] == true && send("#{name}?") == true
76
+
77
+ log && send(
78
+ :log_item_error_initialize, attr_name: name, message: "Service is missing argument with name #{name}"
79
+ )
80
+ false
81
+ end
82
+ end
83
+
84
+ def define_required_conditional_validator(canonical:, name:, **options)
85
+ define_method(canonical) do
86
+ return false if send(:"valid_required_#{name}?", false) == false
87
+ return true if options[:if].call(send(name))
88
+
89
+ send(
90
+ :log_item_error_initialize,
91
+ attr_name: name,
92
+ message: "Service argument conditional requirement not met properly for #{name}"
93
+ )
94
+ false
95
+ end
96
+ end
97
+
98
+ def define_deprecated_alias(deprecated, canonical)
99
+ define_method(deprecated) do |*args, **kwargs|
100
+ ::Assistant::InputBuilder::RequireValidator.warn_deprecated(deprecated, canonical, caller_locations(1, 1).first)
101
+ send(canonical, *args, **kwargs)
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,24 @@
1
+ # Type signatures for `lib/assistant/input_builder/require_validator.rb`
2
+ # (M9). Generates the canonical `valid_required_*?` and
3
+ # `valid_required_conditional_*?` instance methods plus deprecated
4
+ # `valid_require_*?` aliases.
5
+
6
+ module Assistant::InputBuilder::RequireValidator
7
+ DEPRECATION_WARNED: Set[Array[untyped]]
8
+
9
+ def self.warn_deprecated: (Symbol deprecated_name, Symbol canonical_name, Thread::Backtrace::Location caller_location) -> void
10
+
11
+ def self.__reset_deprecation_warnings__: () -> void
12
+
13
+ def input_require_validator_meth: (name: Symbol, **untyped) -> Symbol
14
+
15
+ def input_require_conditional_meth: (name: Symbol, **untyped) -> Symbol
16
+
17
+ private
18
+
19
+ def define_required_validator: (canonical: Symbol, name: Symbol, **untyped) -> Symbol
20
+
21
+ def define_required_conditional_validator: (canonical: Symbol, name: Symbol, **untyped) -> Symbol
22
+
23
+ def define_deprecated_alias: (Symbol deprecated, Symbol canonical) -> Symbol
24
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generators for the per-input `valid_type_<name>?` validator (M3:
4
+ # multi-type accepted; M2: `allow_nil:` short-circuits the check).
5
+ module Assistant::InputBuilder::TypeValidator
6
+ # Define `#valid_type_<name>?` on the host class.
7
+ #
8
+ # @param name [Symbol] input name
9
+ # @param type [Class, Array<Class>] declared type(s); `Array(type)` is unioned
10
+ # @param options [Hash] remaining `#input` keyword options (reads `:allow_nil`)
11
+ # @return [void]
12
+ def input_type_validator_meth(name:, type:, **options)
13
+ allow_nil = options.fetch(:allow_nil, false) == true
14
+ types = Array(type)
15
+ message_builder = type_mismatch_message_builder(name:, types:)
16
+ body = type_validator_body(name:, types:, allow_nil:, message_builder:)
17
+
18
+ define_method("valid_type_#{name}?", &body)
19
+ end
20
+
21
+ # Builds the Proc body for the per-input valid_type_<name>? method.
22
+ # Extracted to keep input_type_validator_meth under metric limits.
23
+ def type_validator_body(name:, types:, allow_nil:, message_builder:)
24
+ lambda do
25
+ value = @inputs[name]
26
+ # M2: when allow_nil: true is set, any supplied key short-circuits
27
+ # the type check (mirrors the require validator's behaviour).
28
+ next true if allow_nil && @inputs.key?(name)
29
+ next true if types.any? { |klass| value.is_a?(klass) }
30
+
31
+ send("#{name}?") &&
32
+ send(:log_item_error_initialize, attr_name: name, message: message_builder.call(send(name).class))
33
+ false
34
+ end
35
+ end
36
+
37
+ # Returns a Proc that, given the actual class of a failing input,
38
+ # produces the error message. Single-type keeps the original 0.1.0
39
+ # wording for back-compat; multi-type uses "is not one of […]". (M3)
40
+ def type_mismatch_message_builder(name:, types:)
41
+ if types.size == 1
42
+ ->(actual) { "Service argument with name #{name} is not a #{types.first} but #{actual}" }
43
+ else
44
+ ->(actual) { "Service argument with name #{name} is not one of [#{types.join(', ')}] but #{actual}" }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,18 @@
1
+ # Type signatures for `lib/assistant/input_builder/type_validator.rb`
2
+ # (M3 multi-type; M2 `allow_nil:`).
3
+
4
+ module Assistant::InputBuilder::TypeValidator
5
+ def input_type_validator_meth: (name: Symbol, type: untyped, **untyped) -> Symbol
6
+
7
+ def type_validator_body: (
8
+ name: Symbol,
9
+ types: Array[untyped],
10
+ allow_nil: bool,
11
+ message_builder: ^(Class) -> String
12
+ ) -> ^() -> bool
13
+
14
+ def type_mismatch_message_builder: (
15
+ name: Symbol,
16
+ types: Array[untyped]
17
+ ) -> ^(Class) -> String
18
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Umbrella for the input DSL. Each submodule owns one cohesive
4
+ # responsibility; this file wires them into a single `Assistant::InputBuilder`
5
+ # module that `Service` extends. (M13, v1 plan)
6
+ require 'assistant/input_builder/accessors'
7
+ require 'assistant/input_builder/default_option'
8
+ require 'assistant/input_builder/dsl'
9
+ require 'assistant/input_builder/optional_option'
10
+ require 'assistant/input_builder/registry'
11
+ require 'assistant/input_builder/require_validator'
12
+ require 'assistant/input_builder/type_validator'
13
+
14
+ # Declarative input DSL for `Assistant::Service` subclasses. `#input`
15
+ # registers a definition and generates the per-input reader, `?`-checker,
16
+ # type validator, and (when `required:` is set) requirement validator(s).
17
+ # Behaviour is unchanged from pre-M13; the umbrella only re-exports the
18
+ # submodule methods. See the per-submodule files for the specific concern
19
+ # each owns.
20
+ module Assistant::InputBuilder
21
+ include Registry
22
+ include DefaultOption
23
+ include OptionalOption
24
+ include Accessors
25
+ include RequireValidator
26
+ include TypeValidator
27
+ include Dsl
28
+ end
@@ -0,0 +1,15 @@
1
+ # Type signatures for `lib/assistant/input_builder.rb` — the umbrella
2
+ # module that includes every per-concern submodule. The submodule
3
+ # signatures live alongside the corresponding `.rb` files under
4
+ # `lib/assistant/input_builder/`. See docs/v1/01-api-surface.md and
5
+ # docs/v1/02-features.md (M13).
6
+
7
+ module Assistant::InputBuilder
8
+ include Registry
9
+ include DefaultOption
10
+ include OptionalOption
11
+ include Accessors
12
+ include RequireValidator
13
+ include TypeValidator
14
+ include Dsl
15
+ end
@@ -1,26 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Assistant
4
- # Log base class
4
+ # A single structured log entry produced by `Service` (directly via
5
+ # `LogList#add_log` or through one of the `log_item_*` shorthands).
6
+ # Construction is **strict** since 1.0 (M10): invalid attributes raise
7
+ # `ArgumentError` rather than producing an instance whose `#valid?`
8
+ # returns `false`. See `docs/v1/06-migration-0x-to-1.md` for the
9
+ # rationale.
10
+ #
11
+ # @example Build an error log entry
12
+ # Assistant::LogItem.new(
13
+ # level: :error,
14
+ # source: :create_user,
15
+ # detail: :email,
16
+ # message: 'must not be blank'
17
+ # )
5
18
  class LogItem
19
+ # The exhaustive set of accepted `level:` values, in display order.
20
+ # @return [Array<Symbol>]
6
21
  VALID_LEVELS = %i[info warning error].freeze
7
22
 
23
+ # Validation rules applied in `#initialize`. Each entry pairs a
24
+ # human message with the predicate that must hold. Internal.
25
+ # @api private
26
+ ERRORS = [
27
+ ["level must be one of [#{VALID_LEVELS.join(', ')}]", :valid_level?],
28
+ ['source must be present and different from detail', :valid_source?],
29
+ ['detail must be present and different from source', :valid_detail?],
30
+ ['message must be present', :valid_message?]
31
+ ].freeze
32
+
33
+ # @return [Symbol] severity (`:info`, `:warning`, or `:error`)
34
+ # @!attribute [r] level
35
+ # @return [Symbol] high-level subsystem the entry came from (e.g. `:initialize`, `:hook`)
36
+ # @!attribute [r] source
37
+ # @return [Symbol] finer-grained detail under `source` (often an attribute name)
38
+ # @!attribute [r] detail
39
+ # @return [String] human-readable message
40
+ # @!attribute [r] message
41
+ # @return [Array<String>, nil] optional backtrace captured at construction
42
+ # @!attribute [r] trace
8
43
  attr_reader :level, :source, :detail, :message, :trace
9
44
 
45
+ # @param level [Symbol, #to_sym] one of {VALID_LEVELS}
46
+ # @param source [Symbol, #to_sym] subsystem identifier; must differ from `detail`
47
+ # @param detail [Symbol, #to_sym] sub-identifier; must differ from `source`
48
+ # @param message [#to_s] human-readable message; must not be blank
49
+ # @param trace [Array<String>, nil] optional backtrace
50
+ # @raise [ArgumentError] when any of the constructor checks in {ERRORS} fail
10
51
  def initialize(level:, source:, detail:, message:, trace: nil)
11
- @level = level.to_sym
12
- @source = source.to_sym
13
- @detail = detail.to_sym
52
+ @level = normalize_symbol(level)
53
+ @source = normalize_symbol(source)
54
+ @detail = normalize_symbol(detail)
14
55
  @message = message.to_s
15
56
  @trace = trace
57
+ validate!
16
58
  end
17
59
 
18
- def valid?
19
- [valid_level?, valid_source?, valid_detail?, valid_message?].all?
20
- end
60
+ # @return [Boolean] always `true` for instances constructed via {#initialize} (which raises on invalid input).
61
+ # Retained for introspection and downstream tooling.
62
+ def valid? = [valid_level?, valid_source?, valid_detail?, valid_message?].all?
21
63
 
64
+ # @return [Hash{Symbol => Object}] hash view with keys `:level`, `:source`, `:detail`, `:message`, `:trace`
22
65
  def item
23
- { level: level, source: source, detail: detail, message: message, trace: trace }
66
+ { level:, source:, detail:, message:, trace: }
24
67
  end
25
68
 
26
69
  VALID_LEVELS.each do |valid_level|
@@ -30,20 +73,35 @@ module Assistant
30
73
  end
31
74
  end
32
75
 
33
- def valid_level?
34
- VALID_LEVELS.include?(level)
35
- end
76
+ # @return [Boolean] true when `level` is one of {VALID_LEVELS}
77
+ def valid_level? = VALID_LEVELS.include?(level)
78
+
79
+ # @return [Boolean] true when `source` is non-empty and not equal to `detail`
80
+ def valid_source? = present_log_attribute?(source) && detail != source
81
+
82
+ # @return [Boolean] true when `detail` is non-empty and not equal to `source`
83
+ def valid_detail? = present_log_attribute?(detail) && source != detail
36
84
 
37
- def valid_source?
38
- source.size.positive? && detail != source
85
+ # @return [Boolean] true when `message` contains at least one non-whitespace character
86
+ def valid_message? = !message.strip.empty?
87
+
88
+ private
89
+
90
+ def validate!
91
+ errors = validation_errors
92
+ return if errors.empty?
93
+
94
+ raise ArgumentError, "invalid LogItem: #{errors.join('; ')}"
39
95
  end
40
96
 
41
- def valid_detail?
42
- detail.size.positive? && source != detail
97
+ def validation_errors = ERRORS.reject { |_, validation_method| send(validation_method) }.map(&:first)
98
+
99
+ def normalize_symbol(value)
100
+ value.respond_to?(:to_sym) ? value.to_sym : value
43
101
  end
44
102
 
45
- def valid_message?
46
- message.size.positive?
103
+ def present_log_attribute?(value)
104
+ value.respond_to?(:size) && value.size.positive?
47
105
  end
48
106
  end
49
107
  end
@@ -0,0 +1,40 @@
1
+ # Type signatures for `lib/assistant/log_item.rb`. See
2
+ # docs/v1/01-api-surface.md for the frozen surface.
3
+
4
+ class Assistant::LogItem
5
+ VALID_LEVELS: Array[Symbol]
6
+ ERRORS: Array[[String, Symbol]]
7
+
8
+ attr_reader level: Symbol
9
+ attr_reader source: Symbol
10
+ attr_reader detail: Symbol
11
+ attr_reader message: String
12
+ attr_reader trace: untyped
13
+
14
+ def initialize: (
15
+ detail: Symbol | String,
16
+ message: String | _ToS,
17
+ level: Symbol | String,
18
+ source: Symbol | String,
19
+ ?trace: untyped
20
+ ) -> void
21
+
22
+ def valid?: () -> bool
23
+ def valid_level?: () -> bool
24
+ def valid_source?: () -> bool
25
+ def valid_detail?: () -> bool
26
+ def valid_message?: () -> bool
27
+
28
+ def info?: () -> bool
29
+ def warning?: () -> bool
30
+ def error?: () -> bool
31
+
32
+ def item: () -> Hash[Symbol, untyped]
33
+
34
+ private
35
+
36
+ def validate!: () -> void
37
+ def validation_errors: () -> Array[String]
38
+ def normalize_symbol: (untyped value) -> untyped
39
+ def present_log_attribute?: (untyped value) -> bool
40
+ end
@@ -1,20 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Assistant
4
- # Service level list of logs
5
- module LogList
6
- def add_log(level:, source:, detail:, message:, trace: nil)
7
- @logs << Assistant::LogItem.new(level: level, source: source, detail: detail, message: message, trace: trace)
8
- end
3
+ # Mixin that gives `Assistant::Service` its log timeline. Owns the
4
+ # `@logs` array and exposes the public helpers (`#add_log`, `#merge_logs`,
5
+ # the `log_item_*` shorthands, `#infos` / `#warnings` / `#errors`) used
6
+ # by service code to record observations and by callers to read the
7
+ # result.
8
+ module Assistant::LogList
9
+ # Append a single {Assistant::LogItem} to the timeline.
10
+ #
11
+ # @param level [Symbol] `:info`, `:warning`, or `:error`
12
+ # @param source [Symbol] subsystem identifier
13
+ # @param detail [Symbol] sub-identifier (often an attribute name)
14
+ # @param message [String] human-readable message
15
+ # @param trace [Array<String>, nil] optional backtrace
16
+ # @return [Array<Assistant::LogItem>] the updated timeline
17
+ # @raise [ArgumentError] if {Assistant::LogItem#initialize} rejects the inputs
18
+ def add_log(level:, source:, detail:, message:, trace: nil)
19
+ @logs << Assistant::LogItem.new(level:, source:, detail:, message:, trace:)
20
+ end
21
+
22
+ # Concatenate an existing log timeline onto this service's `@logs`,
23
+ # preserving insertion order. Used by `Service#call_service` (M-S2).
24
+ #
25
+ # @param logs [Array<Assistant::LogItem>] entries to append
26
+ # @return [Array<Assistant::LogItem>] the updated timeline
27
+ def merge_logs(logs:)
28
+ @logs.concat(logs)
29
+ end
30
+
31
+ # Convenience used by InputBuilder-generated validators to record an
32
+ # initialization-time error for a specific input attribute.
33
+ #
34
+ # @param attr_name [Symbol] the offending input name (recorded as `detail`)
35
+ # @param message [String] human-readable explanation
36
+ # @return [Array<Assistant::LogItem>] the updated timeline
37
+ def log_item_error_initialize(attr_name:, message:)
38
+ @logs << Assistant::LogItem.new(detail: attr_name, level: :error, message:, source: :initialize)
39
+ end
9
40
 
10
- def merge_logs(other_logs)
11
- @logs.concat(other_logs)
41
+ ::Assistant::LogItem::VALID_LEVELS.each do |level|
42
+ define_method("#{level}s") do
43
+ @logs.select { |log| log.send("#{level}?") }
12
44
  end
13
45
 
14
- ::Assistant::LogItem::VALID_LEVELS.each do |level|
15
- define_method("#{level}s") do
16
- @logs.select { |log| log.send("#{level}?") }
17
- end
46
+ # Shorthand: log_item_info / log_item_warning / log_item_error.
47
+ # See docs/v1/02-features.md M5.
48
+ define_method("log_item_#{level}") do |source:, detail:, message:, trace: nil|
49
+ add_log(level:, source:, detail:, message:, trace:)
18
50
  end
19
51
  end
20
52
  end
@@ -0,0 +1,48 @@
1
+ # Type signatures for `lib/assistant/log_list.rb`. See
2
+ # docs/v1/01-api-surface.md for the frozen surface.
3
+ #
4
+ # `Assistant::LogList` is mixed into `Assistant::Service`; the `@logs`
5
+ # instance variable it operates on is declared on `Service` itself.
6
+
7
+ module Assistant::LogList
8
+ def add_log: (
9
+ detail: Symbol | String,
10
+ level: Symbol | String,
11
+ message: String | _ToS,
12
+ source: Symbol | String,
13
+ ?trace: untyped
14
+ ) -> Array[Assistant::LogItem]
15
+
16
+ def merge_logs: (logs: Array[Assistant::LogItem]) -> Array[Assistant::LogItem]
17
+
18
+ def log_item_error_initialize: (
19
+ attr_name: Symbol | String,
20
+ message: String | _ToS
21
+ ) -> Array[Assistant::LogItem]
22
+
23
+ # M5 shorthands: `log_item_info`, `log_item_warning`, `log_item_error`.
24
+ def log_item_info: (
25
+ detail: Symbol | String,
26
+ message: String | _ToS,
27
+ source: Symbol | String,
28
+ ?trace: untyped
29
+ ) -> Array[Assistant::LogItem]
30
+
31
+ def log_item_warning: (
32
+ detail: Symbol | String,
33
+ message: String | _ToS,
34
+ source: Symbol | String,
35
+ ?trace: untyped
36
+ ) -> Array[Assistant::LogItem]
37
+
38
+ def log_item_error: (
39
+ detail: Symbol | String,
40
+ message: String | _ToS,
41
+ source: Symbol | String,
42
+ ?trace: untyped
43
+ ) -> Array[Assistant::LogItem]
44
+
45
+ def infos: () -> Array[Assistant::LogItem]
46
+ def warnings: () -> Array[Assistant::LogItem]
47
+ def errors: () -> Array[Assistant::LogItem]
48
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Assistant::RbsGenerator
6
+ # Command-line entry point. Parses argv, loads the input files,
7
+ # discovers Service subclasses, renders, writes.
8
+ class Cli
9
+ # Usage banner printed by `--help`.
10
+ # @return [String]
11
+ USAGE = <<~USAGE
12
+ Usage: assistant-rbs [PATH...] [--output DIR] [--quiet]
13
+
14
+ Loads every Ruby file under the given PATHs (default: lib/) and
15
+ writes one .rbs file per Assistant::Service subclass found, under
16
+ DIR (default: sig/).
17
+
18
+ Existing .rbs files without the generator marker comment on their
19
+ first line are left untouched.
20
+ USAGE
21
+
22
+ class << self
23
+ # Convenience wrapper that builds a `Cli` and runs it.
24
+ #
25
+ # @param argv [Array<String>] command-line arguments
26
+ # @param stdout [IO] output stream for non-error messages
27
+ # @param stderr [IO] output stream for warnings and errors
28
+ # @return [Integer] process exit status (0 on success)
29
+ def run(argv, stdout: $stdout, stderr: $stderr)
30
+ new(argv, stdout:, stderr:).run
31
+ end
32
+ end
33
+
34
+ def initialize(argv, stdout: $stdout, stderr: $stderr)
35
+ @argv = argv
36
+ @stdout = stdout
37
+ @stderr = stderr
38
+ @output_dir = Assistant::RbsGenerator::DEFAULT_OUTPUT_DIR
39
+ @quiet = false
40
+ @paths = nil
41
+ end
42
+
43
+ # Returns an exit status (0 on success, non-zero on failure).
44
+ def run
45
+ parse_options!
46
+ before = service_subclasses
47
+ load_paths(@paths || Assistant::RbsGenerator::DEFAULT_INPUT_PATHS)
48
+ emit(service_subclasses - before)
49
+ 0
50
+ rescue SystemExit => e
51
+ e.status
52
+ end
53
+
54
+ private
55
+
56
+ def emit(services)
57
+ writer = Assistant::RbsGenerator::Writer.new(
58
+ output_dir: @output_dir, quiet: @quiet, stdout: @stdout, stderr: @stderr
59
+ )
60
+ services.sort_by(&:name).each do |service_class|
61
+ writer.write(service_class, Assistant::RbsGenerator::Renderer.render(service_class))
62
+ end
63
+ end
64
+
65
+ def parse_options!
66
+ OptionParser.new { |opts| configure_parser(opts) }.then { |p| @paths = p.parse(@argv) }
67
+ end
68
+
69
+ def configure_parser(opts)
70
+ opts.banner = USAGE
71
+ default = Assistant::RbsGenerator::DEFAULT_OUTPUT_DIR
72
+ opts.on('-o', '--output DIR', "Output directory (default: #{default})") do |dir|
73
+ @output_dir = dir
74
+ end
75
+ opts.on('-q', '--quiet', 'Suppress non-error output') { @quiet = true }
76
+ opts.on('-h', '--help', 'Show this message') do
77
+ @stdout.puts opts
78
+ raise SystemExit, 0
79
+ end
80
+ end
81
+
82
+ def load_paths(paths)
83
+ paths.each do |path|
84
+ if File.directory?(path)
85
+ Dir.glob(File.join(path, '**/*.rb')).each { |file| safe_require(file) }
86
+ elsif File.file?(path)
87
+ safe_require(path)
88
+ else
89
+ @stderr.puts "[warn] no such file or directory: #{path}"
90
+ end
91
+ end
92
+ end
93
+
94
+ def safe_require(path)
95
+ require File.expand_path(path)
96
+ rescue LoadError, StandardError => e
97
+ @stderr.puts "[warn] failed to load #{path}: #{e.class}: #{e.message}"
98
+ end
99
+
100
+ # Snapshot of every loaded Service subclass. Used to diff before /
101
+ # after `load_paths` so we only emit signatures for classes whose
102
+ # source files we were actually asked to scan -- this keeps a
103
+ # long-running process (or a test suite) from re-emitting sigs for
104
+ # every Service subclass it has ever seen.
105
+ def service_subclasses
106
+ ObjectSpace.each_object(Class).select { |klass| klass < Assistant::Service && klass.name }
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,24 @@
1
+ class Assistant::RbsGenerator::Cli
2
+ USAGE: String
3
+
4
+ @argv: Array[String]
5
+ @stdout: IO
6
+ @stderr: IO
7
+ @output_dir: String
8
+ @quiet: bool
9
+ @paths: Array[String]?
10
+
11
+ def self.run: (Array[String] argv, ?stdout: IO, ?stderr: IO) -> Integer
12
+
13
+ def initialize: (Array[String] argv, ?stdout: IO, ?stderr: IO) -> void
14
+ def run: () -> Integer
15
+
16
+ private
17
+
18
+ def emit: (Array[singleton(Assistant::Service)] services) -> void
19
+ def parse_options!: () -> void
20
+ def configure_parser: (OptionParser opts) -> void
21
+ def load_paths: (Array[String] paths) -> void
22
+ def safe_require: (String path) -> void
23
+ def service_subclasses: () -> Array[singleton(Assistant::Service)]
24
+ end