docit 0.1.0 → 0.2.1

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.
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ module Docit
7
+ module Ai
8
+ class DocWriter
9
+ def initialize(controller_name:)
10
+ @controller_name = controller_name
11
+ @module_parts = build_module_parts
12
+ end
13
+
14
+ def doc_file_path
15
+ parts = @controller_name.underscore.delete_suffix("_controller").split("/")
16
+ filename = "#{parts.last}_docs.rb"
17
+ dir_parts = parts[0..-2]
18
+
19
+ File.join(Rails.root, "app", "docs", *dir_parts, filename)
20
+ end
21
+
22
+ def doc_module_name
23
+ @controller_name.delete_suffix("Controller").gsub("::", "::") + "Docs"
24
+ end
25
+
26
+ def file_exists?
27
+ File.exist?(doc_file_path)
28
+ end
29
+
30
+ def controller_has_use_docs?
31
+ path = controller_file_path
32
+ return false if path && File.exist?(path) == false
33
+
34
+ File.read(path).include?("use_docs")
35
+ end
36
+
37
+ def inject_use_docs
38
+ path = controller_file_path
39
+ return false if path && File.exist?(path) == false
40
+ return false if controller_has_use_docs?
41
+
42
+ content = File.read(path)
43
+ class_pattern = /^(\s*class\s+\S+.*$)/
44
+ match = content.match(class_pattern)
45
+ return false if match.nil?
46
+
47
+ indent = match[1][/^\s*/] + " "
48
+ use_docs_line = "#{indent}use_docs #{doc_module_name}\n"
49
+ content = content.sub(class_pattern, "\\1\n#{use_docs_line}")
50
+
51
+ File.write(path, content)
52
+ true
53
+ end
54
+
55
+ def write(doc_blocks)
56
+ if file_exists?
57
+ append_to_existing(doc_blocks)
58
+ else
59
+ create_new_file(doc_blocks)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def create_new_file(doc_blocks)
66
+ FileUtils.mkdir_p(File.dirname(doc_file_path))
67
+
68
+ content = build_new_file_content(doc_blocks)
69
+ File.write(doc_file_path, content)
70
+ end
71
+
72
+ def append_to_existing(doc_blocks)
73
+ content = File.read(doc_file_path)
74
+ insertion = doc_blocks.map { |block| indent_block(block, @module_parts.length + 1) }.join("\n\n")
75
+ closing_ends = "end\n" * @module_parts.length
76
+
77
+ content = content.rstrip
78
+ content = content.delete_suffix(closing_ends.rstrip)
79
+ content = "#{content.rstrip}\n\n#{insertion}\n#{closing_ends}"
80
+
81
+ File.write(doc_file_path, content)
82
+ end
83
+
84
+ def build_new_file_content(doc_blocks)
85
+ lines = ["# frozen_string_literal: true", ""]
86
+
87
+ @module_parts.each_with_index do |part, i|
88
+ lines << "#{" " * i}module #{part}"
89
+ end
90
+
91
+ depth = @module_parts.length
92
+ lines << "#{" " * depth}extend Docit::DocFile"
93
+
94
+ doc_blocks.each do |block|
95
+ lines << ""
96
+ lines << indent_block(block, depth)
97
+ end
98
+
99
+ @module_parts.length.times do |i|
100
+ lines << "#{" " * (@module_parts.length - 1 - i)}end"
101
+ end
102
+
103
+ lines.join("\n") + "\n"
104
+ end
105
+
106
+ def indent_block(block, depth)
107
+ prefix = " " * depth
108
+ block.strip.lines.map { |line| line.rstrip.empty? ? "" : "#{prefix}#{line.rstrip}" }.join("\n")
109
+ end
110
+
111
+ def build_module_parts
112
+ doc_module_name.split("::")
113
+ end
114
+
115
+ def controller_file_path
116
+ Rails.root.join("app", "controllers", "#{@controller_name.underscore}.rb").to_s
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module Ai
5
+ class GapDetector
6
+ SKIP_PREFIXES = %w[docit/ rails/ active_storage/ action_mailbox/].freeze
7
+
8
+ def initialize(controller_filter: nil)
9
+ @controller_filter = controller_filter
10
+ end
11
+
12
+ def detect
13
+ RouteInspector.eager_load_controllers!
14
+
15
+ all_routes.each_with_object([]) do |route_info, gaps|
16
+ controller = route_info[:controller]
17
+ action = route_info[:action]
18
+
19
+ next if Registry.find(controller: controller, action: action)
20
+
21
+ routes = RouteInspector.routes_for(controller, action)
22
+ next if routes.empty?
23
+
24
+ gaps << {
25
+ controller: controller,
26
+ action: action,
27
+ path: routes.first[:path],
28
+ method: routes.first[:method]
29
+ }
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def all_routes
36
+ Rails.application.routes.routes.filter_map do |route|
37
+ controller_path = route.defaults[:controller]
38
+ action = route.defaults[:action]
39
+ next if controller_path.nil? || action.nil?
40
+ next if skip_route?(controller_path)
41
+
42
+ controller_class = "#{controller_path}_controller".camelize
43
+ next if @controller_filter && controller_class != @controller_filter
44
+
45
+ { controller: controller_class, action: action }
46
+ end.uniq
47
+ end
48
+
49
+ def skip_route?(controller_path)
50
+ SKIP_PREFIXES.any? { |prefix| controller_path.start_with?(prefix) }
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Docit
8
+ module Ai
9
+ class GroqClient
10
+ API_URL = "https://api.groq.com/openai/v1/chat/completions"
11
+
12
+ def initialize(api_key:, model:)
13
+ @api_key = api_key
14
+ @model = model
15
+ end
16
+
17
+ def generate(prompt)
18
+ uri = URI(API_URL)
19
+ request = Net::HTTP::Post.new(uri)
20
+ request["Authorization"] = "Bearer #{@api_key}"
21
+ request["Content-Type"] = "application/json"
22
+ request.body = {
23
+ model: @model,
24
+ messages: [{ role: "user", content: prompt }],
25
+ temperature: 0.2
26
+ }.to_json
27
+
28
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 15, read_timeout: 60) do |http|
29
+ http.request(request)
30
+ end
31
+
32
+ handle_response(response)
33
+ end
34
+
35
+ private
36
+
37
+ def handle_response(response)
38
+ body = parse_json(response, "Groq")
39
+
40
+ if response.is_a?(Net::HTTPSuccess) == false
41
+ message = body.dig("error", "message") || "Unknown API error"
42
+
43
+ if response.code == "429"
44
+ retry_after = parse_retry_after(response, message)
45
+ raise RateLimitError.new("Groq rate limit exceeded", retry_after: retry_after)
46
+ end
47
+
48
+ raise Error, "Groq API error (#{response.code}): #{message}"
49
+ end
50
+
51
+ body.dig("choices", 0, "message", "content") || raise(Error, "Empty response from Groq")
52
+ end
53
+
54
+ def parse_json(response, provider_name)
55
+ JSON.parse(response.body)
56
+ rescue JSON::ParserError
57
+ raise Error, "#{provider_name} returned invalid JSON (HTTP #{response.code})"
58
+ end
59
+
60
+ def parse_retry_after(response, message)
61
+ # Check Retry-After header first (seconds)
62
+ if (header = response["Retry-After"])
63
+ return header.to_f if header.to_f > 0
64
+ end
65
+
66
+ # Parse "try again in XmY.Zs" from error message
67
+ if message =~ /(\d+)m([\d.]+)s/
68
+ return (Regexp.last_match(1).to_i * 60) + Regexp.last_match(2).to_f
69
+ end
70
+
71
+ if message =~ /([\d.]+)s/
72
+ return Regexp.last_match(1).to_f
73
+ end
74
+
75
+ nil
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Docit
8
+ module Ai
9
+ class OpenaiClient
10
+ API_URL = "https://api.openai.com/v1/chat/completions"
11
+
12
+ def initialize(api_key:, model:)
13
+ @api_key = api_key
14
+ @model = model
15
+ end
16
+
17
+ def generate(prompt)
18
+ uri = URI(API_URL)
19
+ request = Net::HTTP::Post.new(uri)
20
+ request["Authorization"] = "Bearer #{@api_key}"
21
+ request["Content-Type"] = "application/json"
22
+ request.body = {
23
+ model: @model,
24
+ messages: [{ role: "user", content: prompt }],
25
+ temperature: 0.2
26
+ }.to_json
27
+
28
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 15, read_timeout: 60) do |http|
29
+ http.request(request)
30
+ end
31
+
32
+ handle_response(response)
33
+ end
34
+
35
+ private
36
+
37
+ def handle_response(response)
38
+ body = parse_json(response, "OpenAI")
39
+
40
+ if response.is_a?(Net::HTTPSuccess) == false
41
+ message = body.dig("error", "message") || "Unknown API error"
42
+
43
+ if response.code == "429"
44
+ retry_after = response["Retry-After"]&.to_f
45
+ raise RateLimitError.new("OpenAI rate limit exceeded", retry_after: retry_after)
46
+ end
47
+
48
+ raise Error, "OpenAI API error (#{response.code}): #{message}"
49
+ end
50
+
51
+ body.dig("choices", 0, "message", "content") || raise(Error, "Empty response from OpenAI")
52
+ end
53
+
54
+ def parse_json(response, provider_name)
55
+ JSON.parse(response.body)
56
+ rescue JSON::ParserError
57
+ raise Error, "#{provider_name} returned invalid JSON (HTTP #{response.code})"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module Ai
5
+ class PromptBuilder
6
+ DSL_REFERENCE = <<~DSL
7
+ Available DSL methods inside a `doc :action_name do ... end` block:
8
+ summary "Short description"
9
+ description "Detailed description"
10
+ tags "TagName"
11
+ deprecated value: true
12
+ security :bearer_auth
13
+
14
+ parameter :name, location: :query|:path|:header, type: :string|:integer|:boolean, required: true/false, description: "..."
15
+ parameter :id, location: :path, type: :string, required: true
16
+
17
+ request_body required: true do
18
+ property :field, type: :string|:integer|:boolean|:array|:object|:file, required: true/false, example: "value"
19
+ property :field, type: :string, enum: %w[option1 option2]
20
+ property :nested, type: :object do
21
+ property :child, type: :string
22
+ end
23
+ property :items, type: :array do
24
+ property :id, type: :string
25
+ end
26
+ end
27
+
28
+ response 200, "Description" do
29
+ property :field, type: :string, example: "value"
30
+ property :nested, type: :object do
31
+ property :child, type: :string
32
+ end
33
+ end
34
+ response 404, "Not found" do
35
+ property :error, type: :string, example: "Not found"
36
+ end
37
+ DSL
38
+
39
+ EXAMPLE_DOC = <<~EXAMPLE
40
+ doc :register do
41
+ summary "Register a new user"
42
+ description "Creates a customer account and sends verification email"
43
+ tags "Authentication"
44
+
45
+ request_body required: true do
46
+ property :email, type: :string, required: true, example: "user@example.com"
47
+ property :password, type: :string, required: true, format: :password
48
+ property :full_name, type: :string, required: true, example: "John Doe"
49
+ property :role, type: :string, enum: %w[customer provider]
50
+ end
51
+
52
+ response 201, "Account created successfully" do
53
+ property :user_id, type: :string, example: "123e4567-e89b-12d3-a456-426614174000"
54
+ property :email, type: :string, example: "user@example.com"
55
+ end
56
+
57
+ response 422, "Validation error" do
58
+ property :errors, type: :object do
59
+ property :email, type: :array, items: :string
60
+ end
61
+ end
62
+ end
63
+ EXAMPLE
64
+
65
+ def initialize(gap:)
66
+ @gap = gap
67
+ end
68
+
69
+ def build
70
+ <<~PROMPT
71
+ You are generating Docit DSL documentation for a Ruby on Rails API endpoint.
72
+
73
+ #{DSL_REFERENCE}
74
+
75
+ Here is a complete example of a well-documented endpoint:
76
+ #{EXAMPLE_DOC}
77
+
78
+ Now generate documentation for:
79
+ - Controller: #{@gap[:controller]}
80
+ - Action: #{@gap[:action]}
81
+ - HTTP method: #{@gap[:method].upcase}
82
+ - Path: #{@gap[:path]}
83
+
84
+ Controller source code:
85
+ ```ruby
86
+ #{controller_source}
87
+ ```
88
+
89
+ Rules:
90
+ - Output ONLY the `doc :#{@gap[:action]} do ... end` block
91
+ - No module wrapper, no explanation, no markdown fences
92
+ - Infer parameters from the path (e.g., {id} → path parameter)
93
+ - Infer request body from params usage in the controller
94
+ - Infer response structure from render calls
95
+ - Use realistic examples
96
+ - Include appropriate error responses
97
+ - Use the controller name to determine appropriate tags
98
+ PROMPT
99
+ end
100
+
101
+ def source_available?
102
+ path = controller_file_path
103
+ path && File.exist?(path)
104
+ end
105
+
106
+ private
107
+
108
+ def controller_source
109
+ path = controller_file_path
110
+ return "# Source not available" if path && File.exist?(path) == false
111
+ return "# Source not available" if path.nil?
112
+
113
+ File.read(path)
114
+ end
115
+
116
+ def controller_file_path
117
+ return nil if defined?(Rails) == false || Rails.respond_to?(:root) == false || Rails.root.nil?
118
+
119
+ relative = @gap[:controller].underscore
120
+ Rails.root.join("app", "controllers", "#{relative}.rb").to_s
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ module Docit
7
+ module Ai
8
+ class ScaffoldGenerator
9
+ def initialize(output: $stdout)
10
+ @output = output
11
+ @files_written = []
12
+ end
13
+
14
+ def run
15
+ check_base_setup!
16
+ gaps = detect_gaps
17
+
18
+ if gaps.empty?
19
+ @output.puts "No undocumented endpoints found."
20
+ return @files_written
21
+ end
22
+
23
+ @output.puts "Found #{gaps.length} endpoint#{"s" if gaps.length > 1} to scaffold:"
24
+ gaps.each { |g| @output.puts " #{g[:method].upcase} #{g[:path]} (#{g[:controller]}##{g[:action]})" }
25
+ @output.puts ""
26
+
27
+ grouped = gaps.group_by { |g| g[:controller] }
28
+
29
+ grouped.each do |controller, controller_gaps|
30
+ writer = DocWriter.new(controller_name: controller)
31
+
32
+ if writer.file_exists?
33
+ existing_actions = existing_doc_actions(writer.doc_file_path)
34
+ controller_gaps = controller_gaps.reject { |g| existing_actions.include?(g[:action]) }
35
+ next if controller_gaps.empty?
36
+ end
37
+
38
+ blocks = controller_gaps.map { |gap| build_placeholder(gap, controller) }
39
+ writer.write(blocks)
40
+ @files_written << writer.doc_file_path
41
+
42
+ relative = writer.doc_file_path.sub("#{Rails.root}/", "")
43
+ @output.puts " Created: #{relative}"
44
+
45
+ if writer.inject_use_docs
46
+ controller_relative = File.join("app", "controllers", "#{controller.underscore}.rb")
47
+ @output.puts " Added use_docs to #{controller_relative}"
48
+ end
49
+ end
50
+
51
+ inject_tags(grouped)
52
+
53
+ @output.puts ""
54
+ @output.puts "Scaffolded #{gaps.length} endpoint#{"s" if gaps.length > 1} in #{@files_written.length} file#{"s" if @files_written.length > 1}."
55
+ @output.puts "Fill in the TODO placeholders in your doc files."
56
+ @files_written
57
+ end
58
+
59
+ private
60
+
61
+ def detect_gaps
62
+ RouteInspector.eager_load_controllers!
63
+
64
+ detector = GapDetector.new
65
+ detector.detect
66
+ end
67
+
68
+ def check_base_setup!
69
+ unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
70
+ raise Docit::Error, "Docit requires a Rails application. Run this command from your app root."
71
+ end
72
+
73
+ initializer = Rails.root.join("config", "initializers", "docit.rb")
74
+ return unless File.exist?(initializer) == false
75
+
76
+ raise Docit::Error, "Docit is not installed. Run: rails generate docit:install"
77
+ end
78
+
79
+ def build_placeholder(gap, controller)
80
+ tag = derive_tag(controller)
81
+ method = gap[:method].upcase
82
+ path = gap[:path]
83
+
84
+ lines = []
85
+ lines << "doc :#{gap[:action]} do"
86
+ lines << " summary \"TODO: #{method} #{path}\""
87
+ lines << " tags \"#{tag}\""
88
+
89
+ if gap[:path].include?("{")
90
+ path_params = gap[:path].scan(/\{(\w+)\}/).flatten
91
+ path_params.each do |param|
92
+ lines << " parameter :#{param}, location: :path, type: :string, required: true"
93
+ end
94
+ end
95
+
96
+ if %w[POST PUT PATCH].include?(method)
97
+ lines << ""
98
+ lines << " request_body required: true do"
99
+ lines << " # TODO: Add request properties"
100
+ lines << " # property :name, type: :string, required: true"
101
+ lines << " end"
102
+ end
103
+
104
+ lines << ""
105
+ lines << " response #{default_status(method)}, \"TODO: Add description\" do"
106
+ lines << " # TODO: Add response properties"
107
+ lines << " # property :id, type: :integer"
108
+ lines << " end"
109
+ lines << "end"
110
+
111
+ lines.join("\n")
112
+ end
113
+
114
+ def derive_tag(controller)
115
+ controller.delete_suffix("Controller").split("::").last
116
+ end
117
+
118
+ def default_status(method)
119
+ case method
120
+ when "POST" then 201
121
+ when "DELETE" then 204
122
+ else 200
123
+ end
124
+ end
125
+
126
+ def existing_doc_actions(path)
127
+ content = File.read(path)
128
+ content.scan(/doc\s+:(\w+)/).flatten
129
+ end
130
+
131
+ def inject_tags(grouped)
132
+ tags = grouped.keys.map { |c| derive_tag(c) }.uniq
133
+ return if tags.empty?
134
+
135
+ injected = TagInjector.new(tags: tags).inject
136
+ injected.each { |tag| @output.puts " Added tag \"#{tag}\" to config/initializers/docit.rb" }
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module Ai
5
+ class TagInjector
6
+ def initialize(tags:)
7
+ @tags = tags.uniq
8
+ end
9
+
10
+ def inject
11
+ return [] if initializer_path && File.exist?(initializer_path) == false
12
+
13
+ content = File.read(initializer_path)
14
+ existing_tags = content.scan(/config\.tag\s+["']([^"']+)["']/).flatten
15
+
16
+ new_tags = @tags - existing_tags
17
+ return [] if new_tags.empty?
18
+
19
+ lines = new_tags.map do |tag|
20
+ desc = "#{tag} management endpoints"
21
+ " config.tag \"#{tag}\", description: \"#{desc}\""
22
+ end
23
+
24
+ insertion_point = find_insertion_point(content)
25
+ return [] if insertion_point.nil?
26
+
27
+ content = content.insert(insertion_point, "\n#{lines.join("\n")}")
28
+ File.write(initializer_path, content)
29
+
30
+ new_tags
31
+ end
32
+
33
+ private
34
+
35
+ def initializer_path
36
+ return nil if defined?(Rails) == false
37
+
38
+ Rails.root.join("config", "initializers", "docit.rb").to_s
39
+ end
40
+
41
+ def find_insertion_point(content)
42
+ last_tag = content.rindex(/config\.tag\s+/)
43
+ if last_tag
44
+ content.index("\n", last_tag)
45
+ else
46
+ last_config = content.rindex(/config\.\w+/)
47
+ content.index("\n", last_config) if last_config
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
data/lib/docit/ai.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ai/configuration"
4
+ require_relative "ai/client"
5
+ require_relative "ai/openai_client"
6
+ require_relative "ai/anthropic_client"
7
+ require_relative "ai/groq_client"
8
+ require_relative "ai/gap_detector"
9
+ require_relative "ai/prompt_builder"
10
+ require_relative "ai/doc_writer"
11
+ require_relative "ai/tag_injector"
12
+ require_relative "ai/autodoc_runner"
13
+ require_relative "ai/scaffold_generator"
data/lib/docit/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Docit
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/docit.rb CHANGED
@@ -46,3 +46,6 @@ module Docit
46
46
  end
47
47
  end
48
48
  end
49
+
50
+ require_relative "docit/engine" if defined?(Rails::Engine)
51
+ require_relative "docit/ai"