docit 0.4.0 → 0.5.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,127 @@
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
data/lib/docit/ai.rb CHANGED
@@ -12,3 +12,4 @@ 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"
@@ -5,7 +5,8 @@ module Docit
5
5
  class Configuration
6
6
  SUPPORTED_UIS = %i[scalar swagger].freeze
7
7
 
8
- attr_accessor :title, :version, :description, :base_url
8
+ attr_accessor :title, :version, :description, :base_url,
9
+ :system_graph_enabled, :system_graph_excluded_paths
9
10
  attr_reader :default_ui
10
11
 
11
12
  def initialize
@@ -13,6 +14,8 @@ module Docit
13
14
  @version = "1.0.0"
14
15
  @description = "Welcome to the API documentation. Browse the endpoints below to get started."
15
16
  @base_url = "/"
17
+ @system_graph_enabled = true
18
+ @system_graph_excluded_paths = []
16
19
  @default_ui = :scalar
17
20
  @security_schemes = {}
18
21
  @tags = []
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module SystemGraph
5
+ class Edge
6
+ attr_reader :id, :source, :target, :type, :confidence, :evidence
7
+
8
+ def initialize(id:, source:, target:, type:, confidence:, evidence:)
9
+ @id = id.to_s
10
+ @source = source.to_s
11
+ @target = target.to_s
12
+ @type = type.to_s
13
+ @confidence = confidence.to_s
14
+ @evidence = evidence.to_s
15
+ end
16
+
17
+ def to_h
18
+ {
19
+ id: id,
20
+ source: source,
21
+ target: target,
22
+ type: type,
23
+ confidence: confidence,
24
+ evidence: evidence
25
+ }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module SystemGraph
5
+ class Generator
6
+ def self.generate
7
+ new.generate
8
+ end
9
+
10
+ def generate
11
+ RailsAnalyzer.new.analyze.to_h
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Docit
6
+ module SystemGraph
7
+ class Graph
8
+ VERSION = "1.0"
9
+
10
+ attr_reader :nodes, :edges, :framework
11
+
12
+ def initialize(framework: "rails")
13
+ @framework = framework
14
+ @nodes = {}
15
+ @edges = {}
16
+ end
17
+
18
+ def add_node(node)
19
+ nodes[node.id] ||= node
20
+ end
21
+
22
+ def add_edge(edge)
23
+ return if edge.source == edge.target
24
+ return unless nodes.key?(edge.source) && nodes.key?(edge.target)
25
+
26
+ edges[edge.id] ||= edge
27
+ end
28
+
29
+ def to_h
30
+ node_values = nodes.values
31
+ edge_values = edges.values
32
+
33
+ {
34
+ version: VERSION,
35
+ generated_at: Time.now.utc.iso8601,
36
+ framework: framework,
37
+ nodes: node_values.map(&:to_h),
38
+ edges: edge_values.map(&:to_h),
39
+ stats: stats(node_values, edge_values)
40
+ }
41
+ end
42
+
43
+ private
44
+
45
+ def stats(node_values, edge_values)
46
+ {
47
+ nodes: node_values.length,
48
+ edges: edge_values.length,
49
+ node_types: node_values.group_by(&:type).transform_values(&:length),
50
+ edge_types: edge_values.group_by(&:type).transform_values(&:length)
51
+ }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module SystemGraph
5
+ class Node
6
+ attr_reader :id, :type, :label, :metadata, :file, :line, :status
7
+
8
+ def initialize(id:, type:, label:, metadata: {}, file: nil, line: nil, status: nil)
9
+ @id = id.to_s
10
+ @type = type.to_s
11
+ @label = label.to_s
12
+ @metadata = metadata
13
+ @file = file
14
+ @line = line
15
+ @status = status
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ id: id,
21
+ type: type,
22
+ label: label,
23
+ metadata: metadata
24
+ }.tap do |hash|
25
+ hash[:file] = file if file
26
+ hash[:line] = line if line
27
+ hash[:status] = status if status
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,251 @@
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
+ graph
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :graph, :scanner
25
+
26
+ def add_routes_and_actions
27
+ route_infos.each do |info|
28
+ controller_id = node_id("controller", info[:controller])
29
+ action_id = "#{controller_id}##{info[:action]}"
30
+ route_id = "route:#{info[:method]}:#{info[:path]}"
31
+ operation = Registry.find(controller: info[:controller], action: info[:action])
32
+
33
+ graph.add_node(Node.new(
34
+ id: controller_id,
35
+ type: "controller",
36
+ label: info[:controller],
37
+ file: controller_file(info[:controller]),
38
+ metadata: { controller_path: info[:controller_path] }
39
+ ))
40
+ graph.add_node(Node.new(
41
+ id: action_id,
42
+ type: "action",
43
+ label: "#{info[:controller]}##{info[:action]}",
44
+ file: controller_file(info[:controller]),
45
+ status: operation ? "documented" : "undocumented",
46
+ metadata: { action: info[:action], http_method: info[:method], path: info[:path] }
47
+ ))
48
+ graph.add_node(Node.new(
49
+ id: route_id,
50
+ type: "route",
51
+ label: "#{info[:method].upcase} #{info[:path]}",
52
+ metadata: { method: info[:method], path: info[:path] },
53
+ status: operation ? "documented" : "undocumented"
54
+ ))
55
+
56
+ graph.add_edge(edge(route_id, action_id, "routes_to", "high", "Rails route table"))
57
+ graph.add_edge(edge(controller_id, action_id, "contains", "high", "Rails controller action"))
58
+ add_doc_node(info, operation, action_id) if operation
59
+ end
60
+ end
61
+
62
+ def add_doc_node(info, operation, action_id)
63
+ doc_id = "doc:#{info[:controller].underscore}:#{info[:action]}"
64
+
65
+ request_body_info = nil
66
+ if operation._request_body
67
+ request_body_info = {
68
+ required: operation._request_body.required,
69
+ content_type: operation._request_body.content_type,
70
+ schema_ref: operation._request_body.schema_ref,
71
+ properties: operation._request_body.properties
72
+ }
73
+ end
74
+
75
+ responses_info = operation._responses.map do |res|
76
+ {
77
+ status: res.status,
78
+ description: res.description,
79
+ schema_ref: res.schema_ref,
80
+ properties: res.properties,
81
+ examples: res.examples
82
+ }
83
+ end
84
+
85
+ parameters_info = operation._parameters.params.map do |param|
86
+ {
87
+ name: param[:name],
88
+ location: param[:in],
89
+ type: param[:schema][:type],
90
+ required: param[:required],
91
+ description: param[:description]
92
+ }
93
+ end
94
+
95
+ graph.add_node(Node.new(
96
+ id: doc_id,
97
+ type: "doc",
98
+ label: operation._summary || "#{info[:controller]}##{info[:action]}",
99
+ metadata: {
100
+ controller: info[:controller],
101
+ action: info[:action],
102
+ description: operation._description,
103
+ tags: operation._tags,
104
+ request_body: request_body_info,
105
+ responses: responses_info,
106
+ parameters: parameters_info
107
+ },
108
+ status: "documented"
109
+ ))
110
+ graph.add_edge(edge(doc_id, action_id, "documents", "high", "Docit registry"))
111
+ end
112
+
113
+ def add_schemas
114
+ Docit.schemas.each_key do |name|
115
+ graph.add_node(Node.new(
116
+ id: node_id("schema", name),
117
+ type: "schema",
118
+ label: name.to_s,
119
+ metadata: { properties: Docit.schemas[name].properties.map { |prop| prop[:name].to_s } }
120
+ ))
121
+ end
122
+ end
123
+
124
+ def add_models
125
+ return unless defined?(ActiveRecord::Base)
126
+
127
+ ActiveRecord::Base.descendants.each do |model|
128
+ next if model.name.nil?
129
+
130
+ model_id = node_id("model", model.name)
131
+ graph.add_node(Node.new(
132
+ id: model_id,
133
+ type: "model",
134
+ label: model.name,
135
+ file: model_file(model.name),
136
+ metadata: { table_name: table_name(model) }
137
+ ))
138
+ add_model_associations(model, model_id)
139
+ end
140
+ end
141
+
142
+ def add_model_associations(model, model_id)
143
+ return unless model.respond_to?(:reflect_on_all_associations)
144
+
145
+ model.reflect_on_all_associations.each do |association|
146
+ target = association.class_name
147
+ target_id = node_id("model", target)
148
+ next unless graph.nodes.key?(target_id)
149
+
150
+ graph.add_edge(edge(model_id, target_id, "association", "high", "ActiveRecord reflection: #{association.name}"))
151
+ end
152
+ end
153
+
154
+ def add_source_nodes
155
+ source_nodes = scanner.source_nodes
156
+ source_nodes.each { |node| graph.add_node(node) }
157
+
158
+ 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|
160
+ scanner.references_for(source.file, labels).each do |label|
161
+ target = graph.nodes.values.find { |node| node.label == label }
162
+ next unless target
163
+
164
+ graph.add_edge(edge(source.id, target.id, edge_type_for(target), "medium", "Constant reference in #{source.file}"))
165
+ end
166
+ end
167
+ end
168
+
169
+ def route_infos
170
+ return [] if defined?(Rails).nil? || Rails.application.routes.nil?
171
+
172
+ Rails.application.routes.routes.filter_map do |route|
173
+ controller_path = route.defaults[:controller]
174
+ action = route.defaults[:action]
175
+ next if controller_path.nil? || action.nil?
176
+ next if skip_route?(controller_path)
177
+
178
+ method = extract_verb(route)
179
+ next if VALID_METHODS.exclude?(method)
180
+
181
+ controller = "#{controller_path}_controller".camelize
182
+ next if excluded_path?(controller_file(controller))
183
+
184
+ {
185
+ controller: controller,
186
+ controller_path: controller_path,
187
+ action: action.to_s,
188
+ method: method,
189
+ path: normalize_path(route.path.spec.to_s)
190
+ }
191
+ end.uniq
192
+ end
193
+
194
+ def edge(source, target, type, confidence, evidence)
195
+ Edge.new(
196
+ id: "#{type}:#{source}->#{target}",
197
+ source: source,
198
+ target: target,
199
+ type: type,
200
+ confidence: confidence,
201
+ evidence: evidence
202
+ )
203
+ end
204
+
205
+ def node_id(type, label)
206
+ "#{type}:#{label.to_s.underscore.tr('/', ':')}"
207
+ end
208
+
209
+ def controller_file(controller)
210
+ "app/controllers/#{controller.underscore}.rb"
211
+ end
212
+
213
+ def model_file(model)
214
+ "app/models/#{model.underscore}.rb"
215
+ end
216
+
217
+ def edge_type_for(target)
218
+ target.type == "model" ? "uses_model" : "calls"
219
+ end
220
+
221
+ def table_name(model)
222
+ model.table_name if model.respond_to?(:table_name)
223
+ rescue ActiveRecord::StatementInvalid
224
+ nil
225
+ end
226
+
227
+ def skip_route?(controller_path)
228
+ SKIP_PREFIXES.any? { |prefix| controller_path.start_with?(prefix) }
229
+ end
230
+
231
+ def excluded_path?(path)
232
+ Docit.configuration.system_graph_excluded_paths.any? do |excluded|
233
+ path.start_with?(excluded.to_s)
234
+ end
235
+ end
236
+
237
+ def extract_verb(route)
238
+ verb = route.verb
239
+ verb = verb.source if verb.is_a?(Regexp)
240
+ verb.to_s.downcase.gsub(/[^a-z]/, "")
241
+ end
242
+
243
+ def normalize_path(path)
244
+ path
245
+ .gsub("(.:format)", "")
246
+ .gsub(/\(\.?:(\w+)\)/, '{\1}')
247
+ .gsub(/:(\w+)/, '{\1}')
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,77 @@
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
45
+ attr_reader :excluded_paths
46
+
47
+ def scan_dir(dir)
48
+ full_dir = root.join(dir)
49
+ return [] unless Dir.exist?(full_dir)
50
+
51
+ Dir.glob(full_dir.join("**", "*.rb")).sort.reject do |path|
52
+ excluded?(relative_path(path))
53
+ end
54
+ end
55
+
56
+ def relative_path(path)
57
+ path.to_s.sub("#{root}/", "")
58
+ end
59
+
60
+ def constant_name(relative, dir)
61
+ relative.delete_prefix("#{dir}/").delete_suffix(".rb").camelize
62
+ end
63
+
64
+ def node_id(type, label)
65
+ "#{type}:#{label.underscore.tr('/', ':')}"
66
+ end
67
+
68
+ def full_path(path)
69
+ root.join(path)
70
+ end
71
+
72
+ def excluded?(path)
73
+ excluded_paths.any? { |excluded| path.start_with?(excluded) }
74
+ end
75
+ end
76
+ end
77
+ 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"