docit 0.4.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.
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module SystemGraph
5
+ class RailsAnalyzer
6
+ VALID_METHODS = %w[get post put patch delete].freeze
7
+ SKIP_PREFIXES = %w[docit/ rails/ active_storage/ action_mailbox/].freeze
8
+
9
+ def initialize(graph: Graph.new, scanner: SourceScanner.new(root: Rails.root))
10
+ @graph = graph
11
+ @scanner = scanner
12
+ end
13
+
14
+ def analyze
15
+ add_routes_and_actions
16
+ add_schemas
17
+ add_models
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
22
+ graph
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :graph, :scanner
28
+
29
+ def add_routes_and_actions
30
+ route_infos.each do |info|
31
+ controller_id = node_id("controller", info[:controller])
32
+ action_id = "#{controller_id}##{info[:action]}"
33
+ route_id = "route:#{info[:method]}:#{info[:path]}"
34
+ operation = Registry.find(controller: info[:controller], action: info[:action])
35
+
36
+ graph.add_node(Node.new(
37
+ id: controller_id,
38
+ type: "controller",
39
+ label: info[:controller],
40
+ file: controller_file(info[:controller]),
41
+ metadata: { controller_path: info[:controller_path] }
42
+ ))
43
+ graph.add_node(Node.new(
44
+ id: action_id,
45
+ type: "action",
46
+ label: "#{info[:controller]}##{info[:action]}",
47
+ file: controller_file(info[:controller]),
48
+ status: operation ? "documented" : "undocumented",
49
+ metadata: { action: info[:action], http_method: info[:method], path: info[:path] }
50
+ ))
51
+ graph.add_node(Node.new(
52
+ id: route_id,
53
+ type: "route",
54
+ label: "#{info[:method].upcase} #{info[:path]}",
55
+ metadata: { method: info[:method], path: info[:path] },
56
+ status: operation ? "documented" : "undocumented"
57
+ ))
58
+
59
+ graph.add_edge(edge(route_id, action_id, "routes_to", "high", "Rails route table"))
60
+ graph.add_edge(edge(controller_id, action_id, "contains", "high", "Rails controller action"))
61
+ add_doc_node(info, operation, action_id) if operation
62
+ end
63
+ end
64
+
65
+ def add_doc_node(info, operation, action_id)
66
+ doc_id = "doc:#{info[:controller].underscore}:#{info[:action]}"
67
+
68
+ request_body_info = nil
69
+ if operation._request_body
70
+ request_body_info = {
71
+ required: operation._request_body.required,
72
+ content_type: operation._request_body.content_type,
73
+ schema_ref: operation._request_body.schema_ref,
74
+ properties: operation._request_body.properties
75
+ }
76
+ end
77
+
78
+ responses_info = operation._responses.map do |res|
79
+ {
80
+ status: res.status,
81
+ description: res.description,
82
+ schema_ref: res.schema_ref,
83
+ properties: res.properties,
84
+ examples: res.examples
85
+ }
86
+ end
87
+
88
+ parameters_info = operation._parameters.params.map do |param|
89
+ {
90
+ name: param[:name],
91
+ location: param[:in],
92
+ type: param[:schema][:type],
93
+ required: param[:required],
94
+ description: param[:description]
95
+ }
96
+ end
97
+
98
+ graph.add_node(Node.new(
99
+ id: doc_id,
100
+ type: "doc",
101
+ label: operation._summary || "#{info[:controller]}##{info[:action]}",
102
+ metadata: {
103
+ controller: info[:controller],
104
+ action: info[:action],
105
+ description: operation._description,
106
+ tags: operation._tags,
107
+ request_body: request_body_info,
108
+ responses: responses_info,
109
+ parameters: parameters_info
110
+ },
111
+ status: "documented"
112
+ ))
113
+ graph.add_edge(edge(doc_id, action_id, "documents", "high", "Docit registry"))
114
+ end
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
+
137
+ def add_schemas
138
+ Docit.schemas.each_key do |name|
139
+ graph.add_node(Node.new(
140
+ id: node_id("schema", name),
141
+ type: "schema",
142
+ label: name.to_s,
143
+ metadata: { properties: Docit.schemas[name].properties.map { |prop| prop[:name].to_s } }
144
+ ))
145
+ end
146
+ end
147
+
148
+ def add_models
149
+ return unless defined?(ActiveRecord::Base)
150
+
151
+ ActiveRecord::Base.descendants.each do |model|
152
+ next if model.name.nil?
153
+
154
+ model_id = node_id("model", model.name)
155
+ graph.add_node(Node.new(
156
+ id: model_id,
157
+ type: "model",
158
+ label: model.name,
159
+ file: model_file(model.name),
160
+ metadata: { table_name: table_name(model) }
161
+ ))
162
+ add_model_associations(model, model_id)
163
+ end
164
+ end
165
+
166
+ def add_model_associations(model, model_id)
167
+ return unless model.respond_to?(:reflect_on_all_associations)
168
+
169
+ model.reflect_on_all_associations.each do |association|
170
+ target = association.class_name
171
+ target_id = node_id("model", target)
172
+ next unless graph.nodes.key?(target_id)
173
+
174
+ graph.add_edge(edge(model_id, target_id, "association", "high",
175
+ "ActiveRecord reflection: #{association.name}"))
176
+ end
177
+ end
178
+
179
+ def add_source_nodes
180
+ source_nodes = scanner.source_nodes
181
+ source_nodes.each { |node| graph.add_node(node) }
182
+
183
+ labels = graph.nodes.values.select { |node| %w[model service job mailer].include?(node.type) }.map(&:label)
184
+ sources = graph.nodes.values.select { |node| %w[controller action service job mailer].include?(node.type) }
185
+ sources.each do |source|
186
+ scanner.references_for(source.file, labels).each do |label|
187
+ target = graph.nodes.values.find { |node| node.label == label }
188
+ next unless target
189
+
190
+ graph.add_edge(edge(source.id, target.id, edge_type_for(target), "medium",
191
+ "Constant reference in #{source.file}"))
192
+ end
193
+ end
194
+ end
195
+
196
+ def route_infos
197
+ return [] if defined?(Rails).nil? || Rails.application.routes.nil?
198
+
199
+ Rails.application.routes.routes.filter_map do |route|
200
+ controller_path = route.defaults[:controller]
201
+ action = route.defaults[:action]
202
+ next if controller_path.nil? || action.nil?
203
+ next if skip_route?(controller_path)
204
+
205
+ method = extract_verb(route)
206
+ next if VALID_METHODS.exclude?(method)
207
+
208
+ controller = "#{controller_path}_controller".camelize
209
+ next if excluded_path?(controller_file(controller))
210
+
211
+ {
212
+ controller: controller,
213
+ controller_path: controller_path,
214
+ action: action.to_s,
215
+ method: method,
216
+ path: normalize_path(route.path.spec.to_s)
217
+ }
218
+ end.uniq
219
+ end
220
+
221
+ def edge(source, target, type, confidence, evidence)
222
+ Edge.new(
223
+ id: "#{type}:#{source}->#{target}",
224
+ source: source,
225
+ target: target,
226
+ type: type,
227
+ confidence: confidence,
228
+ evidence: evidence
229
+ )
230
+ end
231
+
232
+ def node_id(type, label)
233
+ "#{type}:#{label.to_s.underscore.tr("/", ":")}"
234
+ end
235
+
236
+ def controller_file(controller)
237
+ "app/controllers/#{controller.underscore}.rb"
238
+ end
239
+
240
+ def model_file(model)
241
+ "app/models/#{model.underscore}.rb"
242
+ end
243
+
244
+ def edge_type_for(target)
245
+ target.type == "model" ? "uses_model" : "calls"
246
+ end
247
+
248
+ def table_name(model)
249
+ model.table_name if model.respond_to?(:table_name)
250
+ rescue ActiveRecord::StatementInvalid
251
+ nil
252
+ end
253
+
254
+ def skip_route?(controller_path)
255
+ SKIP_PREFIXES.any? { |prefix| controller_path.start_with?(prefix) }
256
+ end
257
+
258
+ def excluded_path?(path)
259
+ Docit.configuration.system_graph_excluded_paths.any? do |excluded|
260
+ path.start_with?(excluded.to_s)
261
+ end
262
+ end
263
+
264
+ def extract_verb(route)
265
+ verb = route.verb
266
+ verb = verb.source if verb.is_a?(Regexp)
267
+ verb.to_s.downcase.gsub(/[^a-z]/, "")
268
+ end
269
+
270
+ def normalize_path(path)
271
+ path
272
+ .gsub("(.:format)", "")
273
+ .gsub(/\(\.?:(\w+)\)/, '{\1}')
274
+ .gsub(/:(\w+)/, '{\1}')
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module SystemGraph
5
+ class SourceScanner
6
+ SOURCE_TYPES = {
7
+ "app/services" => "service",
8
+ "app/jobs" => "job",
9
+ "app/mailers" => "mailer"
10
+ }.freeze
11
+
12
+ def initialize(root:, excluded_paths: Docit.configuration.system_graph_excluded_paths)
13
+ @root = root
14
+ @excluded_paths = excluded_paths.map(&:to_s)
15
+ end
16
+
17
+ def source_nodes
18
+ SOURCE_TYPES.each_with_object([]) do |(dir, type), nodes|
19
+ scan_dir(dir).each do |path|
20
+ relative = relative_path(path)
21
+ label = constant_name(relative, dir)
22
+ nodes << Node.new(
23
+ id: node_id(type, label),
24
+ type: type,
25
+ label: label,
26
+ file: relative,
27
+ metadata: { path: relative }
28
+ )
29
+ end
30
+ end
31
+ end
32
+
33
+ def references_for(path, candidates)
34
+ return [] unless path && File.exist?(full_path(path))
35
+
36
+ content = File.read(full_path(path))
37
+ candidates.select do |candidate|
38
+ content.match?(/\b#{Regexp.escape(candidate)}\b/)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :root, :excluded_paths
45
+
46
+ def scan_dir(dir)
47
+ full_dir = root.join(dir)
48
+ return [] unless Dir.exist?(full_dir)
49
+
50
+ Dir.glob(full_dir.join("**", "*.rb")).sort.reject do |path|
51
+ excluded?(relative_path(path))
52
+ end
53
+ end
54
+
55
+ def relative_path(path)
56
+ path.to_s.sub("#{root}/", "")
57
+ end
58
+
59
+ def constant_name(relative, dir)
60
+ relative.delete_prefix("#{dir}/").delete_suffix(".rb").camelize
61
+ end
62
+
63
+ def node_id(type, label)
64
+ "#{type}:#{label.underscore.tr("/", ":")}"
65
+ end
66
+
67
+ def full_path(path)
68
+ root.join(path)
69
+ end
70
+
71
+ def excluded?(path)
72
+ excluded_paths.any? { |excluded| path.start_with?(excluded) }
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "system_graph/node"
4
+ require_relative "system_graph/edge"
5
+ require_relative "system_graph/graph"
6
+ require_relative "system_graph/source_scanner"
7
+ require_relative "system_graph/rails_analyzer"
8
+ require_relative "system_graph/generator"
@@ -3,10 +3,11 @@
3
3
  module Docit
4
4
  module UI
5
5
  class BaseRenderer
6
- attr_reader :spec_url, :title, :nav_paths
6
+ attr_reader :spec_url, :system_url, :title, :nav_paths
7
7
 
8
- def initialize(spec_url:, nav_paths: {})
8
+ def initialize(spec_url:, system_url: nil, nav_paths: {})
9
9
  @spec_url = spec_url
10
+ @system_url = system_url
10
11
  @nav_paths = nav_paths
11
12
  @title = ERB::Util.html_escape(Docit.configuration.title)
12
13
  end
@@ -20,35 +21,62 @@ module Docit
20
21
  def nav_bar(active:)
21
22
  swagger_active = active == :swagger
22
23
  scalar_active = active == :scalar
24
+ system_active = active == :system
23
25
 
24
26
  <<~HTML
25
- <nav style="
26
- display: flex; align-items: center; gap: 8px;
27
- padding: 6px 16px;
28
- background: #1a1a2e; color: #fff;
29
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
30
- font-size: 13px; position: sticky; top: 0; z-index: 9999;
31
- ">
27
+ <style>
28
+ /* Theme-aware nav. Falls back to the original dark values when the
29
+ page has no theme tokens (e.g. Swagger/Scalar renderers). */
30
+ .docit-nav {
31
+ display: flex; align-items: center; gap: 8px;
32
+ padding: 6px 16px;
33
+ background: var(--bg-glass, #111827);
34
+ color: var(--text, #ffffff);
35
+ border-bottom: 1px solid var(--border, transparent);
36
+ backdrop-filter: blur(12px) saturate(150%);
37
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
38
+ font-size: 13px; position: sticky; top: 0; z-index: 9999;
39
+ }
40
+ .docit-nav-link {
41
+ color: var(--haze, rgba(255,255,255,0.7)); text-decoration: none;
42
+ padding: 4px 12px; border-radius: 6px;
43
+ transition: all 150ms;
44
+ font-family: inherit;
45
+ font-size: inherit;
46
+ }
47
+ .docit-nav-link:hover {
48
+ color: var(--text, #ffffff); background: var(--bg-option-hover, rgba(255,255,255,0.15));
49
+ }
50
+ .docit-nav-link.active {
51
+ color: var(--ember, #ffffff); background: var(--bg-option-hover, rgba(255,255,255,0.15)); font-weight: 600;
52
+ }
53
+ </style>
54
+ <nav class="docit-nav">
32
55
  <span style="font-weight: 600; margin-right: auto;">#{title}</span>
33
- #{nav_link("Swagger", nav_paths[:swagger], active: swagger_active)}
34
- #{nav_link("Scalar", nav_paths[:scalar], active: scalar_active)}
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>
35
59
  </nav>
36
60
  HTML
37
61
  end
38
62
 
39
- def nav_link(label, path, active:)
40
- escaped_path = ERB::Util.html_escape(path)
41
- style = if active
42
- "color: #fff; text-decoration: none; padding: 4px 12px; border-radius: 4px; background: rgba(255,255,255,0.15); font-weight: 500;"
43
- else
44
- "color: rgba(255,255,255,0.7); text-decoration: none; padding: 4px 12px; border-radius: 4px;"
45
- end
63
+ def spec_url_json
64
+ json_escape(JSON.generate(spec_url))
65
+ end
46
66
 
47
- %(<a href="#{escaped_path}" style="#{style}">#{label}</a>)
67
+ def system_url_json
68
+ json_escape(JSON.generate(system_url))
48
69
  end
49
70
 
50
- def spec_url_json
51
- JSON.generate(spec_url)
71
+ def json_escape(json_string)
72
+ json_string.to_s.gsub(/[&<>'\u2028\u2029]/, {
73
+ "&" => '\u0026',
74
+ "<" => '\u003c',
75
+ ">" => '\u003e',
76
+ "'" => '\u0027',
77
+ "\u2028" => '\u2028',
78
+ "\u2029" => '\u2029'
79
+ })
52
80
  end
53
81
  end
54
82
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module UI
5
+ class SystemRenderer < BaseRenderer
6
+ def render
7
+ <<~HTML
8
+ <!DOCTYPE html>
9
+ <html lang="en">
10
+ <head>
11
+ <meta charset="UTF-8">
12
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
13
+ <title>#{title} – System Architecture</title>
14
+ <script>
15
+ /* Apply the saved theme before first paint to avoid a flash.
16
+ Default is light; only switch to dark when explicitly chosen. */
17
+ (function() {
18
+ try {
19
+ var saved = localStorage.getItem("docit-theme");
20
+ if (saved === "dark") document.documentElement.setAttribute("data-theme", "dark");
21
+ } catch (e) {}
22
+ })();
23
+ </script>
24
+ <style>#{SystemStyles.css}</style>
25
+ </head>
26
+ <body>
27
+ #{nav_bar(active: :system)}
28
+ <div class="system-shell">
29
+ <header class="system-toolbar">
30
+ <div class="toolbar-group toolbar-brand">
31
+ <div class="brand-mark" aria-hidden="true">
32
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
33
+ <rect x="2" y="2" width="5" height="5" rx="1.2"/><rect x="9" y="2" width="5" height="5" rx="1.2"/>
34
+ <rect x="2" y="9" width="5" height="5" rx="1.2"/><rect x="9" y="9" width="5" height="5" rx="1.2"/>
35
+ <path d="M7 4.5h2M4.5 7v2M11.5 7v2"/>
36
+ </svg>
37
+ </div>
38
+ <div class="toolbar-title">
39
+ <strong>System Architecture</strong>
40
+ <span>Interactive diagram · drag, zoom, explore</span>
41
+ </div>
42
+ </div>
43
+ <div class="toolbar-group toolbar-filters" id="diagram-filters">
44
+ <input id="search" class="control" type="search" placeholder="Search nodes…" autocomplete="off">
45
+ <select id="diagram-section-filter" class="control"><option value="">All sections</option></select>
46
+ </div>
47
+ <div class="toolbar-group toolbar-filters" id="docs-filters" style="display: none;">
48
+ <select id="section-filter" class="control"><option value="">All sections</option></select>
49
+ </div>
50
+ <div class="toolbar-group toolbar-view-mode" style="margin-left: 6px; border-left: 1px solid var(--border); padding-left: 10px; gap: 4px;">
51
+ <button id="view-diagram" class="system-btn active" type="button">Diagram</button>
52
+ <button id="view-list" class="system-btn" type="button">Docs</button>
53
+ </div>
54
+ <div class="toolbar-group toolbar-zoom">
55
+ <button id="zoom-out" class="system-btn icon-btn" type="button" title="Zoom out" aria-label="Zoom out">
56
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 8h10"/></svg>
57
+ </button>
58
+ <span id="zoom-level" class="zoom-label">100%</span>
59
+ <button id="zoom-in" class="system-btn icon-btn" type="button" title="Zoom in" aria-label="Zoom in">
60
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
61
+ </button>
62
+ <button id="zoom-fit" class="system-btn" type="button">Fit</button>
63
+ </div>
64
+ <div class="toolbar-group toolbar-actions">
65
+ <button id="theme-toggle" class="system-btn icon-btn" type="button" title="Toggle theme" aria-label="Toggle light or dark theme"></button>
66
+ <button id="export-png" class="system-btn" type="button">
67
+ <span class="btn-icon" aria-hidden="true">
68
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v8M5 7.5l3 3 3-3M3 13h10"/></svg>
69
+ </span> Export PNG
70
+ </button>
71
+ </div>
72
+ <div class="toolbar-group toolbar-info">
73
+ <span id="stats" class="stat-pill"></span>
74
+ </div>
75
+ </header>
76
+
77
+ <div class="system-body">
78
+ <main class="canvas-wrap" id="canvas-wrap">
79
+ <div id="canvas" class="canvas"></div>
80
+ <div id="legend" class="legend collapsed">
81
+ <button id="legend-toggle" class="legend-toggle" type="button">Legend ▸</button>
82
+ <div id="legend-content" class="legend-content"></div>
83
+ </div>
84
+ </main>
85
+ <main class="stripe-docs-wrap" id="stripe-docs-wrap" style="display: none;">
86
+ <nav class="stripe-sidebar" id="stripe-sidebar"></nav>
87
+ <div class="stripe-content" id="stripe-content"></div>
88
+ <aside class="stripe-detail" id="stripe-detail">
89
+ <button class="stripe-detail-close" id="stripe-detail-close" type="button" aria-label="Close panel">
90
+ <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
91
+ </button>
92
+ <div class="stripe-detail-body" id="stripe-detail-body">
93
+ <div class="stripe-detail-empty">
94
+ <div class="stripe-detail-empty-icon" aria-hidden="true">
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
+ </div>
97
+ <p>Select an endpoint to see how to call it.</p>
98
+ </div>
99
+ </div>
100
+ </aside>
101
+ </main>
102
+ <aside id="panel" class="panel"></aside>
103
+ </div>
104
+ </div>
105
+ <div id="toast" class="toast" role="status"></div>
106
+ <script>#{SystemScript.javascript(graph_url: system_url)}</script>
107
+ </body>
108
+ </html>
109
+ HTML
110
+ end
111
+ end
112
+ end
113
+ end