assistant 0.1.0 → 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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/PULL_REQUEST_TEMPLATE.md +39 -0
  3. data/.github/workflows/ci.yml +99 -0
  4. data/.github/workflows/docs.yml +64 -0
  5. data/.github/workflows/release.yml +1 -1
  6. data/.gitignore +5 -1
  7. data/.opencode/.gitignore +4 -0
  8. data/.opencode/opencode.json +13 -0
  9. data/.opencode/skills/create-pr/SKILL.md +138 -0
  10. data/.opencode/skills/ruby-services/SKILL.md +81 -0
  11. data/.rubocop.yml +14 -4
  12. data/.yardopts +17 -0
  13. data/CHANGELOG.md +378 -0
  14. data/CONTRIBUTING.md +131 -0
  15. data/Gemfile +10 -0
  16. data/Gemfile.lock +196 -29
  17. data/README.md +125 -16
  18. data/Rakefile +45 -0
  19. data/SECURITY.md +50 -0
  20. data/Steepfile +49 -0
  21. data/_config.yml +87 -0
  22. data/assistant.gemspec +24 -7
  23. data/docs/api-reference.md +264 -0
  24. data/docs/changelog.md +26 -0
  25. data/docs/deprecations.md +86 -0
  26. data/docs/examples/cli-handler.md +17 -0
  27. data/docs/examples/composing-services.md +17 -0
  28. data/docs/examples/execute-callbacks.md +17 -0
  29. data/docs/examples/index.md +29 -0
  30. data/docs/examples/instrumentation-notifier.md +17 -0
  31. data/docs/examples/rails-service.md +17 -0
  32. data/docs/examples/rbs-generator.md +17 -0
  33. data/docs/examples/sidekiq-worker.md +17 -0
  34. data/docs/getting-started.md +136 -0
  35. data/docs/guides/composing-services.md +222 -0
  36. data/docs/guides/index.md +25 -0
  37. data/docs/guides/inputs.md +333 -0
  38. data/docs/guides/logging-and-results.md +202 -0
  39. data/docs/guides/rbs-and-types.md +16 -0
  40. data/docs/guides/validation.md +180 -0
  41. data/docs/index.md +69 -0
  42. data/docs/roadmap.md +33 -0
  43. data/exe/assistant-rbs +7 -0
  44. data/lib/assistant/execute_callbacks.rb +103 -0
  45. data/lib/assistant/execute_callbacks.rbs +30 -0
  46. data/lib/assistant/input_builder/accessors.rb +36 -0
  47. data/lib/assistant/input_builder/accessors.rbs +10 -0
  48. data/lib/assistant/input_builder/default_option.rb +41 -0
  49. data/lib/assistant/input_builder/default_option.rbs +11 -0
  50. data/lib/assistant/input_builder/dsl.rb +37 -0
  51. data/lib/assistant/input_builder/dsl.rbs +12 -0
  52. data/lib/assistant/input_builder/optional_option.rb +45 -0
  53. data/lib/assistant/input_builder/optional_option.rbs +10 -0
  54. data/lib/assistant/input_builder/registry.rb +27 -0
  55. data/lib/assistant/input_builder/registry.rbs +13 -0
  56. data/lib/assistant/input_builder/require_validator.rb +104 -0
  57. data/lib/assistant/input_builder/require_validator.rbs +24 -0
  58. data/lib/assistant/input_builder/type_validator.rb +47 -0
  59. data/lib/assistant/input_builder/type_validator.rbs +18 -0
  60. data/lib/assistant/input_builder.rb +25 -81
  61. data/lib/assistant/input_builder.rbs +15 -0
  62. data/lib/assistant/log_item.rb +74 -16
  63. data/lib/assistant/log_item.rbs +40 -0
  64. data/lib/assistant/log_list.rb +43 -17
  65. data/lib/assistant/log_list.rbs +48 -0
  66. data/lib/assistant/rbs_generator/cli.rb +109 -0
  67. data/lib/assistant/rbs_generator/cli.rbs +24 -0
  68. data/lib/assistant/rbs_generator/renderer.rb +67 -0
  69. data/lib/assistant/rbs_generator/renderer.rbs +11 -0
  70. data/lib/assistant/rbs_generator/writer.rb +65 -0
  71. data/lib/assistant/rbs_generator/writer.rbs +24 -0
  72. data/lib/assistant/rbs_generator.rb +38 -0
  73. data/lib/assistant/rbs_generator.rbs +5 -0
  74. data/lib/assistant/refinements/string_blankness.rb +9 -13
  75. data/lib/assistant/refinements/string_blankness.rbs +6 -0
  76. data/lib/assistant/service.rb +300 -11
  77. data/lib/assistant/service.rbs +82 -1
  78. data/lib/assistant/version.rb +5 -1
  79. data/lib/assistant/version.rbs +5 -0
  80. data/lib/assistant.rb +54 -4
  81. data/lib/assistant.rbs +25 -0
  82. data/mise.toml +2 -0
  83. data/sig/examples/greeter.rbs +14 -0
  84. metadata +142 -38
  85. data/.fasterer.yml +0 -19
  86. data/.rubocop_todo.yml +0 -7
@@ -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
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Converts a Service subclass to a `.rbs` source string. Pure
4
+ # function; does no I/O.
5
+ module Assistant::RbsGenerator::Renderer
6
+ class << self
7
+ # Render a `.rbs` source string for the given `Service` subclass.
8
+ # Module-prefix segments of the class name are wrapped in nested
9
+ # `module ... end` blocks; the trailing segment becomes the
10
+ # `class X < Assistant::Service` declaration. The body lists one
11
+ # `def name: () -> Type` and `def name?: () -> bool` per declared
12
+ # input.
13
+ #
14
+ # @param service_class [Class<Assistant::Service>]
15
+ # @return [String] the rendered `.rbs` source, ending with a newline
16
+ # @raise [RuntimeError] when `service_class` is anonymous or declares
17
+ # an input with a non-Class / anonymous `type:`
18
+ def render(service_class)
19
+ name = service_class.name or raise 'anonymous Service class cannot be rendered'
20
+ segments = name.split('::')
21
+ # `String#split` on a non-empty string always returns at least
22
+ # one element, but Steep can't prove that -- guard for narrowing.
23
+ class_name = segments.pop or raise "unexpected empty name for #{service_class.inspect}"
24
+ body_lines = render_class_body(class_name, service_class.input_definitions)
25
+ nested_lines = nest_in_modules(segments, body_lines)
26
+ "#{[Assistant::RbsGenerator::MARKER, '', *nested_lines].join("\n")}\n"
27
+ end
28
+
29
+ private
30
+
31
+ def render_class_body(class_name, definitions)
32
+ header = "class #{class_name} < Assistant::Service"
33
+ method_lines = definitions.flat_map { |name, options| input_method_lines(name, options) }
34
+ return [header, 'end'] if method_lines.empty?
35
+
36
+ [header, '', *method_lines.map { |line| " #{line}" }, '', 'end']
37
+ end
38
+
39
+ def nest_in_modules(segments, body_lines)
40
+ segments.reverse.reduce(body_lines) do |body, segment|
41
+ indented = body.map { |line| line.empty? ? '' : " #{line}" }
42
+ ["module #{segment}", *indented, 'end']
43
+ end
44
+ end
45
+
46
+ def input_method_lines(name, options)
47
+ [
48
+ "def #{name}: () -> #{render_type(name, options)}",
49
+ "def #{name}?: () -> bool"
50
+ ]
51
+ end
52
+
53
+ def render_type(name, options)
54
+ raise "input #{name.inspect} has no `type:` declared" unless options.key?(:type)
55
+
56
+ rendered = Array(options[:type]).map { |type| render_single_type(name, type) }
57
+ union = rendered.length == 1 ? rendered.first : "(#{rendered.join(' | ')})"
58
+ options[:allow_nil] == true ? "#{union}?" : union
59
+ end
60
+
61
+ def render_single_type(name, type)
62
+ raise "input #{name.inspect} has non-Class type #{type.inspect}" unless type.is_a?(Module)
63
+
64
+ type.name || raise("input #{name.inspect} has anonymous type")
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,11 @@
1
+ module Assistant::RbsGenerator::Renderer
2
+ def self.render: (singleton(Assistant::Service) service_class) -> String
3
+
4
+ private
5
+
6
+ def self.render_class_body: (String class_name, Hash[Symbol, untyped] definitions) -> Array[String]
7
+ def self.nest_in_modules: (Array[String] segments, Array[String] body_lines) -> Array[String]
8
+ def self.input_method_lines: (Symbol name, Hash[Symbol, untyped] options) -> Array[String]
9
+ def self.render_type: (Symbol name, Hash[Symbol, untyped] options) -> String
10
+ def self.render_single_type: (Symbol name, untyped type) -> String
11
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Assistant::RbsGenerator
6
+ # File-writing layer with marker-aware idempotency.
7
+ class Writer
8
+ def initialize(output_dir:, quiet: false, stdout: $stdout, stderr: $stderr)
9
+ @output_dir = output_dir
10
+ @quiet = quiet
11
+ @stdout = stdout
12
+ @stderr = stderr
13
+ end
14
+
15
+ # Returns one of :written, :unchanged, :skipped.
16
+ def write(service_class, contents)
17
+ target = path_for(service_class)
18
+ return skip!(target) if exists_without_marker?(target)
19
+ return unchanged!(target) if File.exist?(target) && File.read(target) == contents
20
+
21
+ FileUtils.mkdir_p(File.dirname(target))
22
+ File.write(target, contents)
23
+ announce("[written] #{target}")
24
+ :written
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :output_dir, :quiet, :stdout, :stderr
30
+
31
+ def exists_without_marker?(target)
32
+ File.exist?(target) && !generated_file?(target)
33
+ end
34
+
35
+ def skip!(target)
36
+ stderr.puts "[skipped] #{target} (no generator marker; will not overwrite)"
37
+ :skipped
38
+ end
39
+
40
+ def unchanged!(target)
41
+ announce("[unchanged] #{target}")
42
+ :unchanged
43
+ end
44
+
45
+ def path_for(service_class)
46
+ name = service_class.name or raise "anonymous Service class #{service_class.inspect}"
47
+ relative = name.split('::').map { |seg| underscore(seg) }.join('/')
48
+ File.join(output_dir, "#{relative}.rbs")
49
+ end
50
+
51
+ # ASCII-only underscoring -- the gem ships no runtime deps, so this
52
+ # mirrors the common ActiveSupport rule without pulling it in.
53
+ def underscore(camel)
54
+ camel.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
55
+ end
56
+
57
+ def generated_file?(path)
58
+ File.foreach(path).first&.chomp == Assistant::RbsGenerator::MARKER
59
+ end
60
+
61
+ def announce(message)
62
+ stdout.puts(message) unless quiet
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,24 @@
1
+ class Assistant::RbsGenerator::Writer
2
+ @output_dir: String
3
+ @quiet: bool
4
+ @stdout: IO
5
+ @stderr: IO
6
+
7
+ def initialize: (output_dir: String, ?quiet: bool, ?stdout: IO, ?stderr: IO) -> void
8
+ def write: (singleton(Assistant::Service) service_class, String contents) -> Symbol
9
+
10
+ private
11
+
12
+ attr_reader output_dir: String
13
+ attr_reader quiet: bool
14
+ attr_reader stdout: IO
15
+ attr_reader stderr: IO
16
+
17
+ def path_for: (singleton(Assistant::Service) service_class) -> String
18
+ def exists_without_marker?: (String target) -> bool
19
+ def skip!: (String target) -> Symbol
20
+ def unchanged!: (String target) -> Symbol
21
+ def underscore: (String camel) -> String
22
+ def generated_file?: (String path) -> bool
23
+ def announce: (String message) -> void
24
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Per-class RBS generator for `Assistant::Service` subclasses. The
4
+ # generic RBS shipped in `lib/assistant/service.rbs` cannot describe the
5
+ # per-input getter / predicate methods produced by the
6
+ # `Assistant::InputBuilder` DSL because their names and return types are
7
+ # only known at class-definition time. Users with Steep run this
8
+ # generator over their service files to obtain accurate signatures for
9
+ # their own subclasses.
10
+ #
11
+ # The generator is intentionally **not** loaded by `require 'assistant'`
12
+ # -- it is a developer-time tool. The shipped `exe/assistant-rbs`
13
+ # binary requires it explicitly.
14
+ #
15
+ # Marked Experimental in `docs/v1/01-api-surface.md` so the output
16
+ # format may evolve within 1.x.
17
+ #
18
+ # See `docs/v1/02-features.md` (M11) for the contract.
19
+ module Assistant::RbsGenerator
20
+ # Header marker written as the first line of every generated `.rbs`
21
+ # file. {Writer} refuses to overwrite a file whose first line is
22
+ # not this exact string, so hand-edited signatures are preserved.
23
+ # @return [String]
24
+ MARKER = '# Generated by assistant-rbs; do not edit.'
25
+
26
+ # Default value for the `--output DIR` option of `exe/assistant-rbs`.
27
+ # @return [String]
28
+ DEFAULT_OUTPUT_DIR = 'sig'
29
+
30
+ # Default value for the positional `PATH` arguments of
31
+ # `exe/assistant-rbs` when none are supplied.
32
+ # @return [Array<String>]
33
+ DEFAULT_INPUT_PATHS = ['lib'].freeze
34
+ end
35
+
36
+ require 'assistant/rbs_generator/cli'
37
+ require 'assistant/rbs_generator/renderer'
38
+ require 'assistant/rbs_generator/writer'
@@ -0,0 +1,5 @@
1
+ module Assistant::RbsGenerator
2
+ MARKER: String
3
+ DEFAULT_OUTPUT_DIR: String
4
+ DEFAULT_INPUT_PATHS: Array[String]
5
+ end
@@ -1,18 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Assistant
4
- module Refinements
5
- # Refines String with `#whitespace?`, true when a string is empty or
6
- # contains only whitespace characters. Used by `InputBuilder` validators
7
- # to treat whitespace-only strings as missing input without depending on
8
- # ActiveSupport's `String#blank?`.
9
- module StringBlankness
10
- refine String do
11
- # True when the string is empty or contains only whitespace.
12
- def whitespace?
13
- strip.empty?
14
- end
15
- end
3
+ # Refines String with `#whitespace?`, true when a string is empty or
4
+ # contains only whitespace characters. Used by `InputBuilder` validators
5
+ # to treat whitespace-only strings as missing input without depending on
6
+ # ActiveSupport's `String#blank?`.
7
+ module Assistant::Refinements::StringBlankness
8
+ refine String do
9
+ # True when the string is empty or contains only whitespace.
10
+ def whitespace?
11
+ strip.empty?
16
12
  end
17
13
  end
18
14
  end
@@ -0,0 +1,6 @@
1
+ # Type signatures for `lib/assistant/refinements/string_blankness.rb`.
2
+ # RBS cannot fully model refinements; the bundled refinement module is
3
+ # declared as an empty module so downstream signatures can name it.
4
+
5
+ module Assistant::Refinements::StringBlankness
6
+ end