docit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,55 @@
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
+ raise Error, "Groq API error (#{response.code}): #{message}"
43
+ end
44
+
45
+ body.dig("choices", 0, "message", "content") || raise(Error, "Empty response from Groq")
46
+ end
47
+
48
+ def parse_json(response, provider_name)
49
+ JSON.parse(response.body)
50
+ rescue JSON::ParserError
51
+ raise Error, "#{provider_name} returned invalid JSON (HTTP #{response.code})"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,55 @@
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
+ raise Error, "OpenAI API error (#{response.code}): #{message}"
43
+ end
44
+
45
+ body.dig("choices", 0, "message", "content") || raise(Error, "Empty response from OpenAI")
46
+ end
47
+
48
+ def parse_json(response, provider_name)
49
+ JSON.parse(response.body)
50
+ rescue JSON::ParserError
51
+ raise Error, "#{provider_name} returned invalid JSON (HTTP #{response.code})"
52
+ end
53
+ end
54
+ end
55
+ 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.0"
5
5
  end
data/lib/docit.rb CHANGED
@@ -46,3 +46,5 @@ module Docit
46
46
  end
47
47
  end
48
48
  end
49
+
50
+ require_relative "docit/ai"
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module Docit
6
+ module Generators
7
+ class AiSetupGenerator < Rails::Generators::Base
8
+ desc "Configures AI provider for Docit autodoc generation"
9
+
10
+ PROVIDER_OPTIONS = {
11
+ "1" => "openai",
12
+ "2" => "anthropic",
13
+ "3" => "groq"
14
+ }.freeze
15
+
16
+ def prompt_provider
17
+ say ""
18
+ say "Select your AI provider:"
19
+ say " 1. OpenAI"
20
+ say " 2. Anthropic"
21
+ say " 3. Groq (free tier available)"
22
+ say ""
23
+
24
+ choice = ask_choice("Enter choice (1-3):", PROVIDER_OPTIONS.keys)
25
+ @provider = PROVIDER_OPTIONS[choice]
26
+
27
+ say "Selected: #{@provider.capitalize}", :green
28
+ end
29
+
30
+ def prompt_api_key
31
+ loop do
32
+ @api_key = ask_secret("Enter your #{@provider.capitalize} API key:")
33
+ return if @api_key.empty? == false
34
+
35
+ say "API key cannot be blank", :red
36
+ end
37
+ end
38
+
39
+ def save_configuration
40
+ model = Docit::Ai::Configuration::DEFAULT_MODELS[@provider]
41
+ Docit::Ai::Configuration.save(
42
+ provider: @provider,
43
+ model: model,
44
+ api_key: @api_key
45
+ )
46
+ say "Saved AI configuration to .docit_ai.yml", :green
47
+ end
48
+
49
+ def update_gitignore
50
+ gitignore = Rails.root.join(".gitignore")
51
+ if File.exist?(gitignore) == false
52
+ say "Warning: .gitignore not found. Add .docit_ai.yml manually to avoid committing your API key.", :yellow
53
+ return
54
+ end
55
+
56
+ content = File.read(gitignore)
57
+ return if content.include?(".docit_ai.yml")
58
+
59
+ File.open(gitignore, "a") do |f|
60
+ f.puts "" if content.end_with?("\n") == false
61
+ f.puts "# Docit AI configuration (contains API key)"
62
+ f.puts ".docit_ai.yml"
63
+ end
64
+ say "Added .docit_ai.yml to .gitignore", :green
65
+ end
66
+
67
+ def print_instructions
68
+ say ""
69
+ say "Docit AI configured successfully!", :green
70
+ say ""
71
+ say "Docit stores your API key in .docit_ai.yml with restricted file permissions."
72
+ say "Keep that file out of version control."
73
+ say ""
74
+ say "Next steps:"
75
+ say " rails docit:autodoc # document all undocumented endpoints"
76
+ say " rails docit:autodoc[Api::V1::UsersController] # document a specific controller"
77
+ say " DRY_RUN=1 rails docit:autodoc # preview without writing files"
78
+ say ""
79
+ say "To reconfigure, run this generator again."
80
+ say ""
81
+ end
82
+
83
+ private
84
+
85
+ def ask_choice(prompt, choices)
86
+ loop do
87
+ choice = ask(prompt).to_s.strip
88
+ return choice if choices.include?(choice)
89
+
90
+ say "Invalid choice. Please enter #{choices.join(", ")}.", :red
91
+ end
92
+ end
93
+
94
+ def ask_secret(prompt)
95
+ if $stdin.respond_to?(:noecho) && $stdin.tty?
96
+ shell.say(prompt, nil, false)
97
+ value = $stdin.noecho(&:gets).to_s.strip
98
+ shell.say("")
99
+ value
100
+ else
101
+ ask(prompt).to_s.strip
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end