docit 0.5.0 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85a7407aab5a5fea2aa0c679d469360f124884ebeac2e4479265cbcdd818210d
4
- data.tar.gz: c946eea9c5a664931630b00726f33b8e5bb296c1493e799c67cd905f330cba1d
3
+ metadata.gz: ad97aecf5e4f97baffb4f944fa8f8a62b449846714e1c2dd2e8bbdc9a167ee22
4
+ data.tar.gz: f638754ff073096c6904db2e5d394413e2f1c579966b97af831cfe93ae91af51
5
5
  SHA512:
6
- metadata.gz: 3dd0a62c6d0f138916839a7bcc445f98921abaf9e0ef477473e4dddba67f2033fd29ffb51a4803597d98f0807ee24c53d6b5a90a8d144d87c54669ee51155fef
7
- data.tar.gz: d953ceef06cd91034fa79503a07da386dcefc7cdddabcb3567d1638d6546a2c484bdebd82f263d732a94b89447a9e9f6b659d7008c322d2e238e002aca286a1b
6
+ metadata.gz: 9efd42b242a8c94f0a4372689205e736819ff0fb8e75fa29e95e708899adefacbefec43af0987449fbd3fd766dbb78b1c3f750d51d3141ee4bcb353d08cf9490
7
+ data.tar.gz: b871fd59eabc84e1f135a4638527f7042b48ee033f17f7bd17378ab7002308e81202c37269912ca5883af30f06c1a7a6a8dc4d121d10573225cb464226f91251
data/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## [0.6.0] - 2026-06-20
2
+
3
+ ### Added
4
+ - Property options for richer schemas: `default`, `nullable`, `read_only`, and `write_only` on any `property` in a request body, response, or shared schema — emitted as OpenAPI `default`, `nullable`, `readOnly`, and `writeOnly`. Falsy values like `default: false` and `default: 0` are preserved
5
+ - Response headers: declare them inside a `response` block with `header "X-RateLimit-Remaining", type: :integer, description: "..."`, emitted as an OpenAPI `headers` object on that response
6
+ - System Map `uses_schema` edges: documented endpoints that reference a shared schema (`schema ref: :User`) are now linked to that schema node in the graph
7
+ - `TROUBLESHOOTING.md` covering common questions (missing routes, ignored options, AI setup, production exposure)
8
+ - README section on restricting access to the docs endpoints in production
9
+
10
+ ### Changed
11
+ - README documents the new property options and response headers
12
+
13
+ ### Removed
14
+ - The AI "Explain section" / system insights feature and its endpoint (`/api-docs/system/insights`). It was unauthenticated — exposing an outbound, billable AI call and rendering AI output without escaping — so it has been removed. The rest of the System Map (diagram, docs view, search, theming, PNG export) is unchanged, and `rails docit:autodoc` is unaffected
15
+
1
16
  ## [0.5.0] - 2026-05-31
2
17
 
3
18
  ### Added
data/README.md CHANGED
@@ -27,6 +27,8 @@ Decorator-style API documentation for Ruby on Rails. Write OpenAPI 3.0.3 docs wi
27
27
  - [Request bodies](#request-bodies)
28
28
  - [Path parameters](#path-parameters)
29
29
  - [Enums](#enums)
30
+ - [Property options](#property-options)
31
+ - [Response headers](#response-headers)
30
32
  - [Security](#security)
31
33
  - [Deprecated endpoints](#deprecated-endpoints)
32
34
  - [Nested objects and arrays](#nested-objects-and-arrays)
@@ -44,12 +46,14 @@ Decorator-style API documentation for Ruby on Rails. Write OpenAPI 3.0.3 docs wi
44
46
  - [System Map](#system-map)
45
47
  - [How it works](#how-it-works)
46
48
  - [Mounting at a different path](#mounting-at-a-different-path)
49
+ - [Restricting access in production](#restricting-access-in-production)
47
50
  - [JSON spec only](#json-spec-only)
48
51
  - [Development](#development)
49
52
  - [Contributing](#contributing)
50
53
  - [License](#license)
51
54
  - Project docs
52
55
  - [CHANGELOG](CHANGELOG.md)
56
+ - [TROUBLESHOOTING](TROUBLESHOOTING.md)
53
57
  - [CONTRIBUTING](CONTRIBUTING.md)
54
58
  - [CODE OF CONDUCT](CODE_OF_CONDUCT.md)
55
59
 
@@ -302,6 +306,37 @@ doc_for :index do
302
306
  end
303
307
  ```
304
308
 
309
+ ### Property options
310
+
311
+ Any `property` (in a request body, response, or shared schema) accepts these OpenAPI 3.0.3 options:
312
+
313
+ ```ruby
314
+ request_body required: true do
315
+ property :id, type: :integer, read_only: true # readOnly: present in responses, not accepted as input
316
+ property :password, type: :string, write_only: true # writeOnly: accepted as input, never returned
317
+ property :role, type: :string, default: "member" # default value
318
+ property :nickname, type: :string, nullable: true # may be null
319
+ property :active, type: :boolean, default: false # falsy defaults are preserved
320
+ end
321
+ ```
322
+
323
+ These map to `readOnly`, `writeOnly`, `default`, and `nullable` in the generated spec.
324
+
325
+ ### Response headers
326
+
327
+ Document the headers a response returns with `header` inside a `response` block:
328
+
329
+ ```ruby
330
+ response 200, "Orders list" do
331
+ header "X-Total-Count", type: :integer, description: "Total orders available"
332
+ header "X-RateLimit-Remaining", type: :integer, description: "Requests left in the window"
333
+
334
+ property :orders, type: :array do
335
+ property :id, type: :integer
336
+ end
337
+ end
338
+ ```
339
+
305
340
  ### Security
306
341
 
307
342
  Mark endpoints as requiring authentication:
@@ -533,6 +568,34 @@ In `config/routes.rb`:
533
568
  mount Docit::Engine => "/docs" # now at /docs instead of /api-docs
534
569
  ```
535
570
 
571
+ ## Restricting access in production
572
+
573
+ The Docit endpoints (`/api-docs`, `/api-docs/spec`, `/api-docs/system`, `/api-docs/system.json`) are **unauthenticated by default** and expose your full API surface, the OpenAPI spec, and — for the System Map — your app's structure and source file paths. That is convenient in development, but in production you almost always want to gate them.
574
+
575
+ Mount the engine inside an authenticated constraint, or behind whatever auth your app already uses:
576
+
577
+ ```ruby
578
+ # config/routes.rb
579
+
580
+ # Option A — only mount outside production
581
+ mount Docit::Engine => "/api-docs" unless Rails.env.production?
582
+
583
+ # Option B — gate behind an authenticated constraint (e.g. Devise admins)
584
+ authenticate :user, ->(u) { u.admin? } do
585
+ mount Docit::Engine => "/api-docs"
586
+ end
587
+
588
+ # Option C — HTTP Basic auth via a Rack constraint
589
+ admins_only = ->(request) do
590
+ ActiveSupport::SecurityUtils.secure_compare(
591
+ request.authorization.to_s, ENV.fetch("DOCS_AUTH", "")
592
+ )
593
+ end
594
+ constraints(admins_only) { mount Docit::Engine => "/api-docs" }
595
+ ```
596
+
597
+ If you disable the System Map specifically, set `config.system_graph_enabled = false` — `/api-docs/system.json` then returns `404`.
598
+
536
599
  ## JSON spec only
537
600
 
538
601
  If you just want the raw OpenAPI JSON (e.g., for code generation):
@@ -0,0 +1,57 @@
1
+ # Troubleshooting
2
+
3
+ Common questions and fixes when working with Docit.
4
+
5
+ ## My route isn't showing up in the spec
6
+
7
+ Docit only documents an endpoint if **both** are true:
8
+
9
+ 1. The route exists in `config/routes.rb` (visible in `rails routes`).
10
+ 2. A doc is registered for that controller + action — either inline with `doc_for :action do ... end` or via `use_docs SomeDocs`.
11
+
12
+ A route with no matching `doc_for`/`use_docs` is simply absent from `/api-docs/spec`. It will still appear on the **System Map** (`/api-docs/system`) marked `undocumented`, which is the quickest way to see your coverage gaps.
13
+
14
+ Checklist:
15
+
16
+ - Is the action name spelled exactly the same in the route and in `doc_for`?
17
+ - Is the controller actually loaded? Docit eager-loads controllers when generating the spec, but a controller in a non-standard path may be missed.
18
+ - If using `use_docs`, does the doc module define a `doc :action` block for that action?
19
+
20
+ ## A doc_for option seems to be ignored
21
+
22
+ Docit's DSL methods are explicit. If you mistype one (e.g. `summmary` instead of `summary`), the call currently does nothing rather than raising — so the option silently disappears.
23
+
24
+ Double-check the spelling of DSL methods against the [DSL reference in the README](README.md). The supported response/property options include `summary`, `description`, `tags`, `response`, `request_body`, `parameter`, `property` (with `type`, `format`, `example`, `enum`, `default`, `nullable`, `read_only`, `write_only`), `header`, `schema ref:`, `security`, `deprecated`, and `operation_id`.
25
+
26
+ ## A System Map section says "undocumented" or shows low coverage
27
+
28
+ That section's endpoints have no registered docs. Add `doc_for`/`use_docs` for those actions, or accept the gap — coverage is informational, not an error.
29
+
30
+ ## Schemas aren't linked on the System Map
31
+
32
+ `uses_schema` edges only appear when a documented endpoint references a shared schema with `schema ref: :Name` (in its request body or a response), and that schema is defined with `Docit.define_schema :Name`. A plain inline `property` block does not create a schema node.
33
+
34
+ ## I documented an action twice and my changes are ignored
35
+
36
+ Docit's registry keeps the **first** doc registered for a given controller + action and ignores later ones. So if an action is already documented (e.g. via `use_docs SomeDocs`) and you also add an inline `doc_for :same_action do ... end`, the inline block is silently dropped — the `use_docs` version wins.
37
+
38
+ Document each action in exactly one place. If you need to override a shared doc module for one action, change it in the module rather than re-declaring it on the controller.
39
+
40
+ ## AI autodoc / scaffolding isn't working
41
+
42
+ - Run `rails generate docit:ai_setup` once to configure a provider (OpenAI, Anthropic, or Groq). The key is written to `.docit_ai.yml`, which the generator adds to `.gitignore` — **never commit it.**
43
+ - `rails docit:autodoc` sends controller **source code** to your configured provider. In an interactive terminal it asks for confirmation first; in a non-interactive context (CI, piping) it proceeds without prompting, so only run it where sending source is acceptable.
44
+ - Preview without writing files: `DRY_RUN=1 rails docit:autodoc`.
45
+ - Rate-limit / 429 errors are retried with backoff automatically; a persistent failure usually means an invalid key or an unsupported model name.
46
+
47
+ ## The System Map or docs pages are exposed in production
48
+
49
+ The Docit endpoints are **unauthenticated by default**. They expose your API surface, the OpenAPI spec, and your app structure. In production, gate the engine — see [Restricting access in production](README.md#restricting-access-in-production) in the README. To disable the System Map specifically, set `config.system_graph_enabled = false`.
50
+
51
+ ## Spec generation is slow on a large app
52
+
53
+ Generating the spec eager-loads controllers and walks every route. For very large apps this is a one-time cost per request; put the docs behind a cache or a non-production-only mount if it matters. The System Map graph is rebuilt per request — if that becomes a bottleneck, mount it behind auth and access it sparingly.
54
+
55
+ ## Still stuck?
56
+
57
+ Open an issue at <https://github.com/S13G/docit/issues> with your Docit version, Rails version, and a minimal `doc_for` / route example.
@@ -21,26 +21,12 @@ module Docit
21
21
  end
22
22
 
23
23
  def system_spec
24
- unless Docit.configuration.system_graph_enabled
25
- return render(json: { error: "System graph disabled" }, status: :not_found)
26
- end
24
+ return render(json: { error: "System graph disabled" }, status: :not_found) unless Docit.configuration.system_graph_enabled
27
25
 
28
26
  RouteInspector.eager_load_controllers!
29
27
  render json: SystemGraph::Generator.generate
30
28
  end
31
29
 
32
- def system_insights
33
- graph = system_graph
34
- insight = Ai::SystemInsightGenerator.new(
35
- graph: graph,
36
- selected_node_ids: selected_node_ids,
37
- mode: insight_mode
38
- ).generate
39
- render json: { insight: insight }
40
- rescue Docit::Error => e
41
- render json: { error: e.message }, status: :unprocessable_entity
42
- end
43
-
44
30
  def spec
45
31
  RouteInspector.eager_load_controllers!
46
32
  render json: SchemaGenerator.generate
@@ -58,27 +44,11 @@ module Docit
58
44
  renderer = RENDERERS.fetch(ui_name).new(
59
45
  spec_url: spec_url,
60
46
  system_url: system_url,
61
- system_insights_url: system_insights_url,
62
47
  nav_paths: nav_paths
63
48
  )
64
49
  render html: renderer.render.html_safe, layout: false
65
50
  end
66
51
 
67
- def system_graph
68
- RouteInspector.eager_load_controllers!
69
- SystemGraph::Generator.generate
70
- end
71
-
72
- def selected_node_ids
73
- params.fetch(:node_ids, "").to_s.split(",").reject(&:empty?)
74
- end
75
-
76
- # Validate at the boundary: only the known modes are honored, anything
77
- # else falls back to the safe per-node default.
78
- def insight_mode
79
- params[:mode].to_s == "section" ? :section : :nodes
80
- end
81
-
82
52
  def spec_url
83
53
  "#{request.base_url}#{Docit::Engine.routes.url_helpers.spec_path}"
84
54
  end
@@ -87,10 +57,6 @@ module Docit
87
57
  "#{request.base_url}#{Docit::Engine.routes.url_helpers.system_spec_path}"
88
58
  end
89
59
 
90
- def system_insights_url
91
- "#{request.base_url}#{Docit::Engine.routes.url_helpers.system_insights_path}"
92
- end
93
-
94
60
  def nav_paths
95
61
  helpers = Docit::Engine.routes.url_helpers
96
62
  { swagger: helpers.swagger_path, scalar: helpers.scalar_path, system: helpers.system_path }
data/config/routes.rb CHANGED
@@ -5,7 +5,6 @@ Docit::Engine.routes.draw do
5
5
  get "swagger", to: "ui#swagger"
6
6
  get "scalar", to: "ui#scalar"
7
7
  get "system.json", to: "ui#system_spec", defaults: { format: :json }, as: :system_spec
8
- get "system/insights", to: "ui#system_insights", defaults: { format: :json }, as: :system_insights
9
8
  get "system", to: "ui#system", as: :system
10
9
  get "spec", to: "ui#spec", defaults: { format: :json }
11
10
  end
Binary file
Binary file
@@ -57,9 +57,7 @@ module Docit
57
57
  end
58
58
 
59
59
  initializer = Rails.root.join("config", "initializers", "docit.rb")
60
- if File.exist?(initializer) == false
61
- raise Docit::Error, "Docit is not installed. Run: rails generate docit:install"
62
- end
60
+ raise Docit::Error, "Docit is not installed. Run: rails generate docit:install" if File.exist?(initializer) == false
63
61
 
64
62
  routes_file = Rails.root.join("config", "routes.rb")
65
63
  return unless File.exist?(routes_file) && !File.read(routes_file).include?("Docit::Engine")
@@ -179,8 +177,15 @@ module Docit
179
177
  end
180
178
  end
181
179
 
180
+ endpoints = pluralize(@results[:generated], "endpoint")
181
+ files = pluralize(@results[:files].length, "file")
182
182
  @output.puts ""
183
- @output.puts "Generated docs for #{@results[:generated]} endpoint#{"s" if @results[:generated] > 1} in #{@results[:files].length} file#{"s" if @results[:files].length > 1}."
183
+ @output.puts "Generated docs for #{endpoints} in #{files}."
184
+ end
185
+
186
+ # "1 endpoint" / "2 endpoints" — keeps the summary lines short and readable.
187
+ def pluralize(count, noun)
188
+ "#{count} #{noun}#{"s" unless count == 1}"
184
189
  end
185
190
 
186
191
  def inject_tags(generated)
@@ -20,7 +20,7 @@ module Docit
20
20
  end
21
21
 
22
22
  def doc_module_name
23
- @controller_name.delete_suffix("Controller").gsub("::", "::") + "Docs"
23
+ "#{@controller_name.delete_suffix("Controller").gsub("::", "::")}Docs"
24
24
  end
25
25
 
26
26
  def file_exists?
@@ -44,7 +44,7 @@ module Docit
44
44
  match = content.match(class_pattern)
45
45
  return false if match.nil?
46
46
 
47
- indent = match[1][/^\s*/] + " "
47
+ indent = "#{match[1][/^\s*/]} "
48
48
  use_docs_line = "#{indent}use_docs #{doc_module_name}\n"
49
49
  content = content.sub(class_pattern, "\\1\n#{use_docs_line}")
50
50
 
@@ -100,7 +100,7 @@ module Docit
100
100
  lines << "#{" " * (@module_parts.length - 1 - i)}end"
101
101
  end
102
102
 
103
- lines.join("\n") + "\n"
103
+ "#{lines.join("\n")}\n"
104
104
  end
105
105
 
106
106
  def indent_block(block, depth)
@@ -59,18 +59,14 @@ module Docit
59
59
 
60
60
  def parse_retry_after(response, message)
61
61
  # Check Retry-After header first (seconds)
62
- if (header = response["Retry-After"])
63
- return header.to_f if header.to_f > 0
62
+ if (header = response["Retry-After"]) && header.to_f.positive?
63
+ return header.to_f
64
64
  end
65
65
 
66
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
67
+ return (Regexp.last_match(1).to_i * 60) + Regexp.last_match(2).to_f if message =~ /(\d+)m([\d.]+)s/
70
68
 
71
- if message =~ /([\d.]+)s/
72
- return Regexp.last_match(1).to_f
73
- end
69
+ return Regexp.last_match(1).to_f if message =~ /([\d.]+)s/
74
70
 
75
71
  nil
76
72
  end
@@ -50,8 +50,10 @@ module Docit
50
50
 
51
51
  inject_tags(grouped)
52
52
 
53
+ endpoints = "endpoint".pluralize(gaps.length)
54
+ files = "file".pluralize(@files_written.length)
53
55
  @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}."
56
+ @output.puts "Scaffolded #{gaps.length} #{endpoints} in #{@files_written.length} #{files}."
55
57
  @output.puts "Fill in the TODO placeholders in your doc files."
56
58
  @files_written
57
59
  end
@@ -24,7 +24,7 @@ module Docit
24
24
  insertion_point = find_insertion_point(content)
25
25
  return [] if insertion_point.nil?
26
26
 
27
- content = content.insert(insertion_point, "\n#{lines.join("\n")}")
27
+ content.insert(insertion_point, "\n#{lines.join("\n")}")
28
28
  File.write(initializer_path, content)
29
29
 
30
30
  new_tags
data/lib/docit/ai.rb CHANGED
@@ -12,4 +12,3 @@ require_relative "ai/doc_writer"
12
12
  require_relative "ai/tag_injector"
13
13
  require_relative "ai/autodoc_runner"
14
14
  require_relative "ai/scaffold_generator"
15
- require_relative "ai/system_insight_generator"
@@ -3,15 +3,16 @@
3
3
  module Docit
4
4
  module Builders
5
5
  # Builds the schema for a single HTTP response, including properties,
6
- # examples, and schema references.
6
+ # examples, headers, and schema references.
7
7
  class ResponseBuilder
8
- attr_reader :status, :description, :properties, :examples, :schema_ref
8
+ attr_reader :status, :description, :properties, :examples, :headers, :schema_ref
9
9
 
10
10
  def initialize(status:, description:)
11
11
  @status = status
12
12
  @description = description
13
13
  @properties = []
14
14
  @examples = []
15
+ @headers = []
15
16
  @schema_ref = nil
16
17
  end
17
18
 
@@ -19,6 +20,15 @@ module Docit
19
20
  @schema_ref = ref.to_sym
20
21
  end
21
22
 
23
+ # Declares a response header, e.g. a rate-limit or pagination header:
24
+ # header "X-RateLimit-Remaining", type: :integer, description: "..."
25
+ def header(name, type: :string, description: nil, example: nil)
26
+ entry = { name: name.to_s, type: type.to_s }
27
+ entry[:description] = description if description
28
+ entry[:example] = example unless example.nil?
29
+ @headers << entry
30
+ end
31
+
22
32
  def property(name, type:, format: nil, example: nil, enum: nil, description: nil, items: nil, **opts, &block)
23
33
  prop = { name: name, type: type }
24
34
  prop[:format] = format if format
@@ -27,9 +27,7 @@ module Docit
27
27
 
28
28
  def default_ui=(value)
29
29
  ui = value.to_sym
30
- unless SUPPORTED_UIS.include?(ui)
31
- raise ArgumentError, "Unsupported UI: #{value}. Must be one of: #{SUPPORTED_UIS.join(", ")}"
32
- end
30
+ raise ArgumentError, "Unsupported UI: #{value}. Must be one of: #{SUPPORTED_UIS.join(", ")}" unless SUPPORTED_UIS.include?(ui)
33
31
 
34
32
  @default_ui = ui
35
33
  end
@@ -104,10 +104,21 @@ module Docit
104
104
  entry[:content] = content
105
105
  end
106
106
 
107
+ entry[:headers] = build_response_headers(builder.headers) if builder.headers.any?
108
+
107
109
  hash[builder.status.to_s] = entry
108
110
  end
109
111
  end
110
112
 
113
+ def build_response_headers(headers)
114
+ headers.each_with_object({}) do |header, hash|
115
+ schema = { schema: { type: header[:type] } }
116
+ schema[:description] = header[:description] if header[:description]
117
+ schema[:schema][:example] = header[:example] if header.key?(:example)
118
+ hash[header[:name]] = schema
119
+ end
120
+ end
121
+
111
122
  def build_request_body(request_body_builder)
112
123
  if request_body_builder.schema_ref
113
124
  schema = { "$ref" => "#/components/schemas/#{request_body_builder.schema_ref}" }
@@ -138,43 +149,50 @@ module Docit
138
149
 
139
150
  def build_property_schema(prop)
140
151
  type = prop[:type].to_s
152
+ schema = base_property_schema(type, prop)
153
+
154
+ # These OpenAPI fields apply to any property shape, so set them once here
155
+ # rather than in each branch above. nil-checks let `false`/`0` through as
156
+ # explicit values (e.g. default: false), but skip unset options.
157
+ schema[:description] = prop[:description] if prop[:description]
158
+ schema[:example] = prop[:example] if prop[:example]
159
+ schema[:default] = prop[:default] unless prop[:default].nil?
160
+ schema[:nullable] = prop[:nullable] unless prop[:nullable].nil?
161
+ schema[:readOnly] = prop[:read_only] unless prop[:read_only].nil?
162
+ schema[:writeOnly] = prop[:write_only] unless prop[:write_only].nil?
163
+ schema
164
+ end
141
165
 
142
- if type == "file"
143
- schema = { type: "string", format: "binary" }
144
- schema[:description] = prop[:description] if prop[:description]
145
- schema
146
- elsif type == "array"
147
- schema = { type: "array" }
148
- if prop[:children]
149
- schema[:items] = {
150
- type: "object",
151
- properties: build_properties(prop[:children])
152
- }
166
+ def base_property_schema(type, prop)
167
+ case type
168
+ when "file"
169
+ { type: "string", format: "binary" }
170
+ when "array"
171
+ { type: "array", items: array_items_schema(prop) }
172
+ else
173
+ # An object with declared children becomes a nested schema; everything
174
+ # else (including a childless "object") is a scalar with type/format/enum.
175
+ if type == "object" && prop[:children]
176
+ { type: "object", properties: build_properties(prop[:children]) }
153
177
  else
154
- items_type = prop[:items]&.to_s || "string"
155
- schema[:items] = { type: items_type }
178
+ scalar_property_schema(type, prop)
156
179
  end
157
- schema[:description] = prop[:description] if prop[:description]
158
- schema[:example] = prop[:example] if prop[:example]
159
- schema
160
- elsif type == "object" && prop[:children]
161
- schema = {
162
- type: "object",
163
- properties: build_properties(prop[:children])
164
- }
165
- schema[:description] = prop[:description] if prop[:description]
166
- schema[:example] = prop[:example] if prop[:example]
167
- schema
168
- else
169
- schema = { type: type }
170
- schema[:format] = prop[:format].to_s if prop[:format]
171
- schema[:enum] = prop[:enum] if prop[:enum]
172
- schema[:example] = prop[:example] if prop[:example]
173
- schema[:description] = prop[:description] if prop[:description]
174
- schema
175
180
  end
176
181
  end
177
182
 
183
+ def array_items_schema(prop)
184
+ return { type: "object", properties: build_properties(prop[:children]) } if prop[:children]
185
+
186
+ { type: prop[:items]&.to_s || "string" }
187
+ end
188
+
189
+ def scalar_property_schema(type, prop)
190
+ schema = { type: type }
191
+ schema[:format] = prop[:format].to_s if prop[:format]
192
+ schema[:enum] = prop[:enum] if prop[:enum]
193
+ schema
194
+ end
195
+
178
196
  def build_component_schemas
179
197
  Docit.schemas.each_with_object({}) do |(name, definition), hash|
180
198
  schema = {
@@ -16,6 +16,9 @@ module Docit
16
16
  add_schemas
17
17
  add_models
18
18
  add_source_nodes
19
+ # Runs last: Graph#add_edge drops edges to not-yet-created nodes, so
20
+ # schema-usage edges must be added after both doc and schema nodes exist.
21
+ add_schema_usage_edges
19
22
  graph
20
23
  end
21
24
 
@@ -110,6 +113,27 @@ module Docit
110
113
  graph.add_edge(edge(doc_id, action_id, "documents", "high", "Docit registry"))
111
114
  end
112
115
 
116
+ # Link each doc node to every shared schema it references via $ref (in its
117
+ # request body or any response). Runs as a final pass because add_edge only
118
+ # keeps edges whose endpoints already exist as nodes. Confidence is high —
119
+ # the references come straight from the Docit registry.
120
+ def add_schema_usage_edges
121
+ graph.nodes.values.select { |node| node.type == "doc" }.each do |doc|
122
+ operation = Registry.find(controller: doc.metadata[:controller], action: doc.metadata[:action])
123
+ next unless operation
124
+
125
+ schema_refs_for(operation).each do |ref|
126
+ graph.add_edge(edge(doc.id, node_id("schema", ref), "uses_schema", "high", "Docit registry $ref"))
127
+ end
128
+ end
129
+ end
130
+
131
+ def schema_refs_for(operation)
132
+ refs = [operation._request_body&.schema_ref]
133
+ operation._responses.each { |res| refs << res.schema_ref }
134
+ refs.compact.uniq
135
+ end
136
+
113
137
  def add_schemas
114
138
  Docit.schemas.each_key do |name|
115
139
  graph.add_node(Node.new(
@@ -147,7 +171,8 @@ module Docit
147
171
  target_id = node_id("model", target)
148
172
  next unless graph.nodes.key?(target_id)
149
173
 
150
- graph.add_edge(edge(model_id, target_id, "association", "high", "ActiveRecord reflection: #{association.name}"))
174
+ graph.add_edge(edge(model_id, target_id, "association", "high",
175
+ "ActiveRecord reflection: #{association.name}"))
151
176
  end
152
177
  end
153
178
 
@@ -156,12 +181,14 @@ module Docit
156
181
  source_nodes.each { |node| graph.add_node(node) }
157
182
 
158
183
  labels = graph.nodes.values.select { |node| %w[model service job mailer].include?(node.type) }.map(&:label)
159
- graph.nodes.values.select { |node| %w[controller action service job mailer].include?(node.type) }.each do |source|
184
+ sources = graph.nodes.values.select { |node| %w[controller action service job mailer].include?(node.type) }
185
+ sources.each do |source|
160
186
  scanner.references_for(source.file, labels).each do |label|
161
187
  target = graph.nodes.values.find { |node| node.label == label }
162
188
  next unless target
163
189
 
164
- graph.add_edge(edge(source.id, target.id, edge_type_for(target), "medium", "Constant reference in #{source.file}"))
190
+ graph.add_edge(edge(source.id, target.id, edge_type_for(target), "medium",
191
+ "Constant reference in #{source.file}"))
165
192
  end
166
193
  end
167
194
  end
@@ -203,7 +230,7 @@ module Docit
203
230
  end
204
231
 
205
232
  def node_id(type, label)
206
- "#{type}:#{label.to_s.underscore.tr('/', ':')}"
233
+ "#{type}:#{label.to_s.underscore.tr("/", ":")}"
207
234
  end
208
235
 
209
236
  def controller_file(controller)
@@ -41,8 +41,7 @@ module Docit
41
41
 
42
42
  private
43
43
 
44
- attr_reader :root
45
- attr_reader :excluded_paths
44
+ attr_reader :root, :excluded_paths
46
45
 
47
46
  def scan_dir(dir)
48
47
  full_dir = root.join(dir)
@@ -62,7 +61,7 @@ module Docit
62
61
  end
63
62
 
64
63
  def node_id(type, label)
65
- "#{type}:#{label.underscore.tr('/', ':')}"
64
+ "#{type}:#{label.underscore.tr("/", ":")}"
66
65
  end
67
66
 
68
67
  def full_path(path)
@@ -3,12 +3,11 @@
3
3
  module Docit
4
4
  module UI
5
5
  class BaseRenderer
6
- attr_reader :spec_url, :system_url, :system_insights_url, :title, :nav_paths
6
+ attr_reader :spec_url, :system_url, :title, :nav_paths
7
7
 
8
- def initialize(spec_url:, system_url: nil, system_insights_url: nil, nav_paths: {})
8
+ def initialize(spec_url:, system_url: nil, nav_paths: {})
9
9
  @spec_url = spec_url
10
10
  @system_url = system_url
11
- @system_insights_url = system_insights_url
12
11
  @nav_paths = nav_paths
13
12
  @title = ERB::Util.html_escape(Docit.configuration.title)
14
13
  end
@@ -54,9 +53,9 @@ module Docit
54
53
  </style>
55
54
  <nav class="docit-nav">
56
55
  <span style="font-weight: 600; margin-right: auto;">#{title}</span>
57
- <a href="#{ERB::Util.html_escape(nav_paths[:swagger])}" class="docit-nav-link #{'active' if swagger_active}">Swagger</a>
58
- <a href="#{ERB::Util.html_escape(nav_paths[:scalar])}" class="docit-nav-link #{'active' if scalar_active}">Scalar</a>
59
- <a href="#{ERB::Util.html_escape(nav_paths[:system])}" class="docit-nav-link #{'active' if system_active}">System</a>
56
+ <a href="#{ERB::Util.html_escape(nav_paths[:swagger])}" class="docit-nav-link #{"active" if swagger_active}">Swagger</a>
57
+ <a href="#{ERB::Util.html_escape(nav_paths[:scalar])}" class="docit-nav-link #{"active" if scalar_active}">Scalar</a>
58
+ <a href="#{ERB::Util.html_escape(nav_paths[:system])}" class="docit-nav-link #{"active" if system_active}">System</a>
60
59
  </nav>
61
60
  HTML
62
61
  end
@@ -69,19 +68,15 @@ module Docit
69
68
  json_escape(JSON.generate(system_url))
70
69
  end
71
70
 
72
- def system_insights_url_json
73
- json_escape(JSON.generate(system_insights_url))
74
- end
75
-
76
71
  def json_escape(json_string)
77
72
  json_string.to_s.gsub(/[&<>'\u2028\u2029]/, {
78
- '&' => '\u0026',
79
- '<' => '\u003c',
80
- '>' => '\u003e',
81
- "'" => '\u0027',
82
- "\u2028" => '\u2028',
83
- "\u2029" => '\u2029'
84
- })
73
+ "&" => '\u0026',
74
+ "<" => '\u003c',
75
+ ">" => '\u003e',
76
+ "'" => '\u0027',
77
+ "\u2028" => '\u2028',
78
+ "\u2029" => '\u2029'
79
+ })
85
80
  end
86
81
  end
87
82
  end
@@ -94,7 +94,7 @@ module Docit
94
94
  <div class="stripe-detail-empty-icon" aria-hidden="true">
95
95
  <svg width="26" height="26" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 1.5h6L13 5v9.5H3z"/><path d="M9 1.5V5h4"/><path d="M5.5 8.5h5M5.5 11h3"/></svg>
96
96
  </div>
97
- <p>Select an endpoint to see how to call it, or use <strong>Explain section</strong> for an AI overview.</p>
97
+ <p>Select an endpoint to see how to call it.</p>
98
98
  </div>
99
99
  </div>
100
100
  </aside>
@@ -103,7 +103,7 @@ module Docit
103
103
  </div>
104
104
  </div>
105
105
  <div id="toast" class="toast" role="status"></div>
106
- <script>#{SystemScript.javascript(graph_url: system_url, insights_url: system_insights_url)}</script>
106
+ <script>#{SystemScript.javascript(graph_url: system_url)}</script>
107
107
  </body>
108
108
  </html>
109
109
  HTML
@@ -3,7 +3,7 @@
3
3
  module Docit
4
4
  module UI
5
5
  module SystemScript
6
- def self.javascript(graph_url:, insights_url:)
6
+ def self.javascript(graph_url:)
7
7
  <<~JS
8
8
  (function() {
9
9
  "use strict";
@@ -11,7 +11,6 @@ module Docit
11
11
  /* ───────────────────────── Configuration ───────────────────────── */
12
12
 
13
13
  const graphUrl = #{json_escape(JSON.generate(graph_url))};
14
- const insightsUrl = #{json_escape(JSON.generate(insights_url))};
15
14
 
16
15
  const TYPE_COLORS = {
17
16
  route: "#4da6ff",
@@ -110,7 +109,6 @@ module Docit
110
109
  return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 16 16" fill="none" stroke="currentColor" ' +
111
110
  'stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">' + paths + '</svg>';
112
111
  }
113
- const ICON_SPARKLE = '<path d="M8 1.5l1.6 4.3 4.4 1.6-4.4 1.6L8 13.4 6.4 9 2 7.4l4.4-1.6L8 1.5z"/>';
114
112
  const ICON_CLICK = '<path d="M6 7V3.5a1.2 1.2 0 0 1 2.4 0V7m0-1.2a1.2 1.2 0 0 1 2.4 0V7m0-.6a1.2 1.2 0 0 1 2.4 0v3.2a4 4 0 0 1-4 4H9a4 4 0 0 1-3.3-1.7L3.5 8.8a1.2 1.2 0 0 1 2-1.3L6 8"/>';
115
113
  const ICON_DRAG = '<path d="M3 8h10M8 3l5 5-5 5M3 8l5-5M3 8l5 5"/>';
116
114
  const ICON_SEARCH = '<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5l3 3"/>';
@@ -694,33 +692,6 @@ module Docit
694
692
  render();
695
693
  }
696
694
 
697
- function formatMarkdown(text) {
698
- return text
699
- /* Mermaid blocks — label them clearly and show as readable code */
700
- .replace(/```mermaid[\\s\\S]*?```/g, function(block) {
701
- var code = block.replace(/```mermaid\\n?/, "").replace(/```$/, "").trim();
702
- return '<div style="margin:10px 0">' +
703
- '<div style="font-size:10px;color:var(--haze);font-family:monospace;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px">Flow diagram</div>' +
704
- '<pre style="background:var(--bg-code);border:1px solid var(--border);border-radius:8px;padding:12px;font-size:11px;color:var(--smoke);overflow-x:auto;line-height:1.6">' + esc(code) + '</pre>' +
705
- '</div>';
706
- })
707
- /* Generic code blocks */
708
- .replace(/```[\\s\\S]*?```/g, function(block) {
709
- var code = block.replace(/```\\w*\\n?/, "").replace(/```$/, "").trim();
710
- return '<pre style="background:var(--bg-code);border:1px solid var(--border);border-radius:8px;padding:10px;font-size:11px;color:var(--smoke);overflow-x:auto">' + esc(code) + '</pre>';
711
- })
712
- .replace(/\\*\\*(.+?)\\*\\*/g, '<strong style="color:var(--text)">$1</strong>')
713
- .replace(/`([^`]+)`/g, '<code style="background:var(--bg-code);border:1px solid var(--border);padding:1px 5px;border-radius:4px;font-size:11px;color:var(--smoke)">$1</code>')
714
- /* Bullet lists — ember accent bar instead of purple */
715
- .replace(/^- (.+)$/gm, '<div style="padding:3px 0 3px 12px;border-left:2px solid rgba(245,121,58,.35);margin:4px 0;color:var(--smoke)">$1</div>')
716
- /* Numbered lists — ember number instead of purple */
717
- .replace(/^(\\d+)\\. (.+)$/gm, '<div style="display:flex;gap:8px;padding:3px 0;margin:4px 0"><span style="color:var(--ember);font-weight:600;font-size:11px;min-width:18px">$1.</span><span style="color:var(--smoke)">$2</span></div>')
718
- /* Inline arrows — keep readable */
719
- .replace(/\\u2192|\\u2194|\\->/g, '<span style="color:var(--ember)">&rarr;</span>')
720
- .replace(/\\n\\n/g, '<br>')
721
- .replace(/\\n/g, ' ');
722
- }
723
-
724
695
  /* ───────────────────────── Node Detail Panel ───────────────────────── */
725
696
 
726
697
  function showNodeDetail(id) {
@@ -1214,57 +1185,11 @@ module Docit
1214
1185
  return '<div class="detail-section"><div class="detail-section-title">Responses</div>' + rows + '</div>';
1215
1186
  }
1216
1187
 
1217
- /* Render the AI section explanation into the panel. The
1218
- undocumented-endpoint warning is handled by the caller. */
1219
- function showSectionExplain(controllerId) {
1220
- var ids = sectionActionIds(controllerId);
1221
- var controller = graph.nodes.find(function(n) { return n.id === controllerId; });
1222
- var sectionName = controller ? resourceName(controller.label).plural : "Section";
1223
-
1224
- if (ids.length === 0) {
1225
- openDetailPanel('<div class="detail-kicker">Section</div><h2 class="detail-title">' + esc(sectionName) +
1226
- '</h2><div class="detail-error">No endpoints to explain in this section.</div>');
1227
- return;
1228
- }
1229
-
1230
- openDetailPanel('<div class="detail-kicker">Section</div><h2 class="detail-title">' + esc(sectionName) + '</h2>' +
1231
- '<div class="detail-ai-head">' + lineIcon(ICON_SPARKLE) + ' How this section works</div>' +
1232
- '<div class="detail-loading">Generating explanation…</div>');
1233
-
1234
- var url = insightsUrl + "?mode=section&node_ids=" + encodeURIComponent(ids.join(","));
1235
- fetch(url)
1236
- .then(function(r) { return r.json(); })
1237
- .then(function(data) {
1238
- if (data.error) throw new Error(data.error);
1239
- openDetailPanel('<div class="detail-kicker">Section</div><h2 class="detail-title">' + esc(sectionName) + '</h2>' +
1240
- '<div class="detail-ai-head">' + lineIcon(ICON_SPARKLE) + ' How this section works</div>' +
1241
- '<div class="detail-ai-body">' + formatMarkdown(data.insight) + '</div>');
1242
- })
1243
- .catch(function(err) {
1244
- openDetailPanel('<div class="detail-kicker">Section</div><h2 class="detail-title">' + esc(sectionName) + '</h2>' +
1245
- '<div class="detail-error">Explanation unavailable: ' + esc(err.message) +
1246
- '<br>Run <span class="mono">rails generate docit:ai_setup</span> to configure an AI provider.</div>');
1247
- });
1248
- }
1249
-
1250
- /* One-time delegation on the docs content: section Explain + endpoint clicks. */
1188
+ /* One-time delegation on the docs content: endpoint clicks. */
1251
1189
  (function bindDocsInteractions() {
1252
1190
  var content = $("stripe-content");
1253
1191
  if (!content) return;
1254
1192
  content.addEventListener("click", function(e) {
1255
- var sectionBtn = e.target.closest(".stripe-explain-btn");
1256
- if (sectionBtn) {
1257
- /* Gate: warn before spending tokens on a thinly-documented section. */
1258
- if (sectionBtn.dataset.fulldoc !== "1") {
1259
- var ok = window.confirm(
1260
- sectionBtn.dataset.undoc + " endpoint(s) in this section are undocumented.\\n\\n" +
1261
- "The explanation may be weak and will use more tokens. For the best result, " +
1262
- "document these endpoints first.\\n\\nGenerate anyway?");
1263
- if (!ok) return;
1264
- }
1265
- showSectionExplain(sectionBtn.dataset.section);
1266
- return;
1267
- }
1268
1193
  /* Relation cards have their own behavior (jump to the diagram). */
1269
1194
  if (e.target.closest(".stripe-relation-card")) return;
1270
1195
  /* A click on an endpoint card (or its View details button) opens
@@ -1309,15 +1234,14 @@ module Docit
1309
1234
  if (actions.length === 0) return;
1310
1235
 
1311
1236
  const controllerAnchor = "controller-" + controller.id.replace(/:/g, "-");
1312
-
1237
+
1313
1238
  sidebarHtml += '<div class="stripe-sidebar-group">';
1314
1239
  sidebarHtml += '<div class="stripe-sidebar-heading">' + esc(controller.label.replace("Controller", "")) + '</div>';
1315
1240
 
1316
1241
  var res = resourceName(controller.label);
1317
1242
 
1318
- /* Documentation coverage drives both the badge and the AI gate:
1319
- a fully-documented section gives the model real input; a thin
1320
- one yields a weak explanation and still costs tokens. */
1243
+ /* Documentation coverage badge: how many of the section's
1244
+ endpoints carry a Docit doc-block. */
1321
1245
  var documentedCount = actions.filter(function(a) { return a.status === "documented"; }).length;
1322
1246
  var totalCount = actions.length;
1323
1247
  var fullyDocumented = documentedCount === totalCount;
@@ -1332,9 +1256,6 @@ module Docit
1332
1256
  contentHtml += ' <div class="stripe-controller-aside">';
1333
1257
  contentHtml += ' <span class="stripe-coverage" style="color:' + coverageColor + ';border-color:' + coverageColor + '40;background:' + coverageColor + '12">' +
1334
1258
  documentedCount + '/' + totalCount + ' documented</span>';
1335
- contentHtml += ' <button class="stripe-explain-btn" type="button" ' +
1336
- 'data-section="' + esc(controller.id) + '" data-fulldoc="' + (fullyDocumented ? "1" : "0") +
1337
- '" data-undoc="' + (totalCount - documentedCount) + '">Explain section</button>';
1338
1259
  contentHtml += ' </div>';
1339
1260
  contentHtml += ' </div>';
1340
1261
  contentHtml += ' <p class="stripe-controller-sub">' + actions.length + ' endpoint' + (actions.length === 1 ? '' : 's') +
@@ -1482,13 +1403,13 @@ module Docit
1482
1403
 
1483
1404
  def self.json_escape(json_string)
1484
1405
  json_string.to_s.gsub(/[&<>'\u2028\u2029]/, {
1485
- '&' => '\u0026',
1486
- '<' => '\u003c',
1487
- '>' => '\u003e',
1488
- "'" => '\u0027',
1489
- "\u2028" => '\u2028',
1490
- "\u2029" => '\u2029'
1491
- })
1406
+ "&" => '\u0026',
1407
+ "<" => '\u003c',
1408
+ ">" => '\u003e',
1409
+ "'" => '\u0027',
1410
+ "\u2028" => '\u2028',
1411
+ "\u2029" => '\u2029'
1412
+ })
1492
1413
  end
1493
1414
  end
1494
1415
  end
@@ -68,10 +68,10 @@ module Docit
68
68
  button, input, select { font: inherit; }
69
69
 
70
70
  /* Inline icon alignment — SVG glyphs sit centered on the text baseline. */
71
- .btn-icon, .tip-icon, .welcome-icon, .detail-ai-head svg {
71
+ .btn-icon, .tip-icon, .welcome-icon {
72
72
  display: inline-flex; align-items: center; justify-content: center;
73
73
  }
74
- .btn-icon svg, .detail-ai-head svg { vertical-align: middle; }
74
+ .btn-icon svg { vertical-align: middle; }
75
75
 
76
76
  /* Shell */
77
77
  .system-shell { display: grid; grid-template-rows: auto 1fr; height: calc(100vh - 37px); min-height: 640px; }
@@ -394,17 +394,9 @@ module Docit
394
394
  font-size: 11px; font-weight: 700; padding: 4px 9px; border-radius: 999px;
395
395
  border: 1px solid; white-space: nowrap;
396
396
  }
397
- .stripe-explain-btn {
398
- display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px;
399
- border: 1px solid rgba(124,77,219,.35); border-radius: 8px;
400
- background: var(--bg-card); color: var(--dusk); font-size: 12px; font-weight: 600;
401
- cursor: pointer; transition: background 120ms ease, border-color 120ms ease;
402
- }
403
- .stripe-explain-btn:hover { background: var(--bg-option-hover); border-color: var(--dusk); }
404
397
 
405
398
  /* ───────── Right-hand detail panel ─────────
406
- Fills the third column: endpoint request/response on click, or the
407
- AI section explanation from "Explain section". */
399
+ Fills the third column: endpoint request/response on click. */
408
400
  .stripe-detail {
409
401
  position: relative; border-left: 1px solid var(--border); background: var(--bg-panel);
410
402
  overflow-y: auto; height: 100%; min-height: 0;
@@ -477,14 +469,6 @@ module Docit
477
469
  .detail-response-head { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
478
470
  .detail-response-desc { font-size: 12px; color: var(--smoke); line-height: 1.45; }
479
471
 
480
- /* AI explanation inside the panel */
481
- .detail-ai-head {
482
- display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700;
483
- color: var(--dusk); margin-bottom: 12px;
484
- }
485
- .detail-ai-body { font-size: 13px; line-height: 1.65; color: var(--smoke); }
486
- .detail-ai-body strong { color: var(--text); }
487
- .detail-loading { color: var(--haze); font-style: italic; font-size: 13px; }
488
472
  .detail-error { color: var(--warning); font-size: 13px; line-height: 1.5; }
489
473
 
490
474
  /* Slide-over drawer on narrow screens */
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.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: docit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - S13G
@@ -41,8 +41,11 @@ files:
41
41
  - LICENSE.txt
42
42
  - README.md
43
43
  - Rakefile
44
+ - TROUBLESHOOTING.md
44
45
  - app/controllers/docit/ui_controller.rb
45
46
  - config/routes.rb
47
+ - docs/images/scalar_image.png
48
+ - docs/images/swagger_image.png
46
49
  - lib/docit.rb
47
50
  - lib/docit/ai.rb
48
51
  - lib/docit/ai/anthropic_client.rb
@@ -56,7 +59,6 @@ files:
56
59
  - lib/docit/ai/openai_client.rb
57
60
  - lib/docit/ai/prompt_builder.rb
58
61
  - lib/docit/ai/scaffold_generator.rb
59
- - lib/docit/ai/system_insight_generator.rb
60
62
  - lib/docit/ai/tag_injector.rb
61
63
  - lib/docit/builders/parameter_builder.rb
62
64
  - lib/docit/builders/request_body_builder.rb
@@ -1,127 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module Docit
6
- module Ai
7
- class SystemInsightGenerator
8
- # mode: :nodes -> explain an arbitrary selection (diagram "AI Explain")
9
- # :section -> explain one resource and how its endpoints work together
10
- def initialize(graph:, selected_node_ids: [], mode: :nodes)
11
- @graph = graph
12
- @selected_node_ids = selected_node_ids
13
- @mode = mode
14
- end
15
-
16
- def generate
17
- config = Configuration.load
18
- Client.for(config).generate(prompt)
19
- end
20
-
21
- private
22
-
23
- attr_reader :graph, :selected_node_ids, :mode
24
-
25
- def prompt
26
- mode == :section ? section_prompt : nodes_prompt
27
- end
28
-
29
- def nodes_prompt
30
- <<~PROMPT
31
- You are a senior engineer explaining a Rails system architecture to a developer. Keep your explanation extremely concise, professional, and clear. Avoid fluff, unnecessary details, or general tutorial information. Focus only on the provided components.
32
-
33
- FORMAT YOUR RESPONSE EXACTLY LIKE THIS:
34
-
35
- ## Overview
36
- A 1-2 sentence plain-English summary of what this component/action does.
37
-
38
- ## Data Flow
39
- Show a simple, linear flow diagram using text and arrows (→). Keep it to 1 line if possible.
40
- Example:
41
- Client → GET /api/v1/users → UsersController#index → queries User model
42
-
43
- ## Connections & Interactions
44
- List only the direct, relevant relationships from the graph (max 3 bullets):
45
- - **Component** (type): Action details/purpose.
46
-
47
- Do not invent or assume anything outside of the provided graph. Keep the total response under 150 words.
48
-
49
- ---
50
-
51
- Selected node ids:
52
- #{selected_node_ids.join("\n")}
53
-
54
- Graph JSON:
55
- #{JSON.pretty_generate(compact_graph)}
56
- PROMPT
57
- end
58
-
59
- def section_prompt
60
- <<~PROMPT
61
- You are a senior engineer writing the introduction to a section of API documentation. The section covers ONE resource and all of its endpoints. Explain, for a developer new to this codebase, what the resource is for and how its endpoints work together as a workflow.
62
-
63
- Use the documented summaries where available. Where an endpoint has no documentation, infer cautiously from its HTTP method and path, and do not fabricate behavior.
64
-
65
- FORMAT YOUR RESPONSE EXACTLY LIKE THIS:
66
-
67
- ## What this section does
68
- 1-2 sentences on the resource and its overall purpose.
69
-
70
- ## How the endpoints work together
71
- A short narrative (2-4 sentences) describing the typical flow across these endpoints — e.g. how a client lists, creates, then updates this resource. Reference endpoints by their HTTP method and path.
72
-
73
- ## Notes
74
- Up to 2 bullets on related models/services or anything a consumer must know. Omit this section if there is nothing concrete to say.
75
-
76
- Do not invent endpoints, fields, or behavior not present in the graph. Keep the total response under 180 words.
77
-
78
- ---
79
-
80
- Endpoint node ids in this section:
81
- #{selected_node_ids.join("\n")}
82
-
83
- Graph JSON:
84
- #{JSON.pretty_generate(compact_graph)}
85
- PROMPT
86
- end
87
-
88
- def compact_graph
89
- nodes = selected_nodes
90
- node_ids = nodes.map { |node| node[:id] }
91
-
92
- # Include edges where at least one end is in the selection
93
- related_edges = graph[:edges].select do |edge|
94
- node_ids.include?(edge[:source]) || node_ids.include?(edge[:target])
95
- end
96
-
97
- # Also include neighbor nodes (one hop away) for context
98
- neighbor_ids = Set.new(node_ids)
99
- related_edges.each do |edge|
100
- neighbor_ids.add(edge[:source])
101
- neighbor_ids.add(edge[:target])
102
- end
103
-
104
- neighbor_nodes = graph[:nodes].select { |node| neighbor_ids.include?(node[:id]) }
105
-
106
- {
107
- selected_nodes: nodes.map { |node| compact_hash(node, %i[id type label status file metadata]) },
108
- context_nodes: (neighbor_nodes - nodes).map { |node| compact_hash(node, %i[id type label status]) },
109
- edges: related_edges.map { |edge| compact_hash(edge, %i[source target type confidence evidence]) },
110
- stats: graph[:stats]
111
- }
112
- end
113
-
114
- def selected_nodes
115
- return graph[:nodes] if selected_node_ids.empty?
116
-
117
- graph[:nodes].select { |node| selected_node_ids.include?(node[:id]) }
118
- end
119
-
120
- def compact_hash(hash, keys)
121
- keys.each_with_object({}) do |key, result|
122
- result[key] = hash[key] if hash.key?(key)
123
- end
124
- end
125
- end
126
- end
127
- end