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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -1
- data/README.md +412 -21
- data/app/controllers/docit/ui_controller.rb +57 -3
- data/config/routes.rb +3 -0
- data/lib/docit/ai/system_insight_generator.rb +127 -0
- data/lib/docit/ai.rb +1 -0
- data/lib/docit/configuration.rb +4 -1
- data/lib/docit/system_graph/edge.rb +29 -0
- data/lib/docit/system_graph/generator.rb +15 -0
- data/lib/docit/system_graph/graph.rb +55 -0
- data/lib/docit/system_graph/node.rb +32 -0
- data/lib/docit/system_graph/rails_analyzer.rb +251 -0
- data/lib/docit/system_graph/source_scanner.rb +77 -0
- data/lib/docit/system_graph.rb +8 -0
- data/lib/docit/ui/base_renderer.rb +54 -21
- data/lib/docit/ui/system_renderer.rb +113 -0
- data/lib/docit/ui/system_script.rb +1495 -0
- data/lib/docit/ui/system_styles.rb +582 -0
- data/lib/docit/version.rb +1 -1
- data/lib/docit.rb +4 -0
- data/lib/generators/docit/install/templates/initializer.rb +4 -0
- metadata +15 -4
|
@@ -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
data/lib/docit/configuration.rb
CHANGED
|
@@ -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,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"
|