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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +63 -0
- data/TROUBLESHOOTING.md +57 -0
- data/app/controllers/docit/ui_controller.rb +1 -35
- data/config/routes.rb +0 -1
- data/docs/images/scalar_image.png +0 -0
- data/docs/images/swagger_image.png +0 -0
- data/lib/docit/ai/autodoc_runner.rb +9 -4
- data/lib/docit/ai/doc_writer.rb +3 -3
- data/lib/docit/ai/groq_client.rb +4 -8
- data/lib/docit/ai/scaffold_generator.rb +3 -1
- data/lib/docit/ai/tag_injector.rb +1 -1
- data/lib/docit/ai.rb +0 -1
- data/lib/docit/builders/response_builder.rb +12 -2
- data/lib/docit/configuration.rb +1 -3
- data/lib/docit/schema_generator.rb +49 -31
- data/lib/docit/system_graph/rails_analyzer.rb +31 -4
- data/lib/docit/system_graph/source_scanner.rb +2 -3
- data/lib/docit/ui/base_renderer.rb +12 -17
- data/lib/docit/ui/system_renderer.rb +2 -2
- data/lib/docit/ui/system_script.rb +12 -91
- data/lib/docit/ui/system_styles.rb +3 -19
- data/lib/docit/version.rb +1 -1
- metadata +4 -2
- data/lib/docit/ai/system_insight_generator.rb +0 -127
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad97aecf5e4f97baffb4f944fa8f8a62b449846714e1c2dd2e8bbdc9a167ee22
|
|
4
|
+
data.tar.gz: f638754ff073096c6904db2e5d394413e2f1c579966b97af831cfe93ae91af51
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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):
|
data/TROUBLESHOOTING.md
ADDED
|
@@ -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 #{
|
|
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)
|
data/lib/docit/ai/doc_writer.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Docit
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def doc_module_name
|
|
23
|
-
@controller_name.delete_suffix("Controller").gsub("::", "::")
|
|
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")
|
|
103
|
+
"#{lines.join("\n")}\n"
|
|
104
104
|
end
|
|
105
105
|
|
|
106
106
|
def indent_block(block, depth)
|
data/lib/docit/ai/groq_client.rb
CHANGED
|
@@ -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
|
|
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}
|
|
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
|
|
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
|
@@ -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
|
data/lib/docit/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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",
|
|
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) }
|
|
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",
|
|
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, :
|
|
6
|
+
attr_reader :spec_url, :system_url, :title, :nav_paths
|
|
7
7
|
|
|
8
|
-
def initialize(spec_url:, system_url: nil,
|
|
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 #{
|
|
58
|
-
<a href="#{ERB::Util.html_escape(nav_paths[:scalar])}" class="docit-nav-link #{
|
|
59
|
-
<a href="#{ERB::Util.html_escape(nav_paths[:system])}" class="docit-nav-link #{
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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
|
|
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
|
|
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)">→</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
|
-
/*
|
|
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
|
|
1319
|
-
|
|
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
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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
|
|
71
|
+
.btn-icon, .tip-icon, .welcome-icon {
|
|
72
72
|
display: inline-flex; align-items: center; justify-content: center;
|
|
73
73
|
}
|
|
74
|
-
.btn-icon svg
|
|
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
|
|
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
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.
|
|
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
|