docwright 0.1.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 +7 -0
- data/.rspec +2 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +25 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/Rakefile +8 -0
- data/examples/.docwright.yml +11 -0
- data/examples/README.md +28 -0
- data/examples/api.md +20 -0
- data/examples/concerns.md +18 -0
- data/examples/database.md +27 -0
- data/examples/models.md +38 -0
- data/lib/docwright/checker.rb +151 -0
- data/lib/docwright/extractors/api_extractor.rb +24 -0
- data/lib/docwright/extractors/auth_extractor.rb +121 -0
- data/lib/docwright/extractors/background_jobs_extractor.rb +149 -0
- data/lib/docwright/extractors/concerns_extractor.rb +93 -0
- data/lib/docwright/extractors/database_extractor.rb +27 -0
- data/lib/docwright/extractors/model_extractor.rb +50 -0
- data/lib/docwright/extractors/services_extractor.rb +60 -0
- data/lib/docwright/generators/feature_generator.rb +74 -0
- data/lib/docwright/generators/manual_generator.rb +135 -0
- data/lib/docwright/merger.rb +59 -0
- data/lib/docwright/railtie.rb +10 -0
- data/lib/docwright/searcher.rb +55 -0
- data/lib/docwright/tasks/docwright.rake +28 -0
- data/lib/docwright/version.rb +5 -0
- data/lib/docwright/wizard.rb +243 -0
- data/lib/docwright.rb +23 -0
- data/sig/docwright.rbs +4 -0
- metadata +106 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docwright
|
|
4
|
+
module Extractors
|
|
5
|
+
class AuthExtractor # rubocop:disable Style/Documentation
|
|
6
|
+
CALLBACK_KINDS = %i[before around after].freeze
|
|
7
|
+
def generate
|
|
8
|
+
Rails.application.eager_load!
|
|
9
|
+
|
|
10
|
+
controllers = find_controllers
|
|
11
|
+
filter_map = build_filter_map(controllers)
|
|
12
|
+
unprotected = find_unprotected(controllers)
|
|
13
|
+
|
|
14
|
+
lines = build_lines(filter_map, unprotected, controllers)
|
|
15
|
+
|
|
16
|
+
FileUtils.mkdir_p("docs")
|
|
17
|
+
Docwright::Merger.write("docs/auth_and_permissions.md", lines.join("\n"))
|
|
18
|
+
puts "DocWright: wrote docs/auth_and_permissions.md"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def find_controllers
|
|
24
|
+
ActionController::Base.descendants
|
|
25
|
+
.reject { |c| c.abstract? rescue false } # rubocop:disable Style/RescueModifier
|
|
26
|
+
.reject { |c| c.name.nil? }
|
|
27
|
+
.reject { |c| c.name.start_with?("ActionController", "ActiveStorage", "Rails") }
|
|
28
|
+
.sort_by(&:name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build_filter_map(controllers) # rubocop:disable Metrics/MethodLength
|
|
32
|
+
filter_map = {}
|
|
33
|
+
|
|
34
|
+
controllers.each do |controller|
|
|
35
|
+
CALLBACK_KINDS.each do |kind|
|
|
36
|
+
before_actions = controller._process_action_callbacks.select { |cb| cb.kind == kind }
|
|
37
|
+
|
|
38
|
+
before_actions.each do |cb|
|
|
39
|
+
filter_name = cb.filter.to_s
|
|
40
|
+
next if cb.filter.is_a?(Proc)
|
|
41
|
+
next if filter_name.start_with?("verify_authenticity_token", "verify_same_origin_request")
|
|
42
|
+
|
|
43
|
+
filter_map[filter_name] ||= { kind: kind, usages: [] }
|
|
44
|
+
filter_map[filter_name][:usages] << {
|
|
45
|
+
controller: controller.name,
|
|
46
|
+
callback: cb
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
filter_map
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def find_unprotected(controllers)
|
|
56
|
+
controllers.select do |controller|
|
|
57
|
+
controller._process_action_callbacks.none? { |cb| cb.kind == :before }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_lines(filter_map, unprotected, controllers) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
|
|
62
|
+
lines = ["# Authentication & Permissions\n"]
|
|
63
|
+
|
|
64
|
+
CALLBACK_KINDS.each do |kind|
|
|
65
|
+
kind_filters = filter_map.select { |_, v| v[:kind] == kind }
|
|
66
|
+
next if kind_filters.empty?
|
|
67
|
+
|
|
68
|
+
lines << "## #{kind.to_s.capitalize} Action Filters\n"
|
|
69
|
+
|
|
70
|
+
kind_filters.each do |filter_name, data|
|
|
71
|
+
lines << "### #{filter_name}"
|
|
72
|
+
defined_in = detect_origin(filter_name)
|
|
73
|
+
lines << "- Defined in: #{defined_in}" if defined_in
|
|
74
|
+
|
|
75
|
+
data[:usages].each do |usage|
|
|
76
|
+
conditions = format_conditions(usage[:callback])
|
|
77
|
+
lines << "- Applied to: #{usage[:controller]}#{conditions}"
|
|
78
|
+
end
|
|
79
|
+
lines << ""
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
unless unprotected.empty?
|
|
84
|
+
lines << "## Unprotected Controllers\n"
|
|
85
|
+
unprotected.each do |c|
|
|
86
|
+
lines << "- #{c.name} — no before_action filters detected"
|
|
87
|
+
end
|
|
88
|
+
lines << ""
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
lines << "## Summary\n"
|
|
92
|
+
lines << "- Total controllers: #{controllers.size}"
|
|
93
|
+
lines << "- Protected: #{controllers.size - unprotected.size}"
|
|
94
|
+
lines << "- Unprotected: #{unprotected.size}"
|
|
95
|
+
|
|
96
|
+
lines
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def detect_origin(filter_name)
|
|
100
|
+
"ApplicationController" if ApplicationController.method_defined?(filter_name.to_sym)
|
|
101
|
+
rescue NameError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def format_conditions(cb)
|
|
106
|
+
conditions = cb.instance_variable_get(:@if) || []
|
|
107
|
+
parts = []
|
|
108
|
+
|
|
109
|
+
conditions.each do |condition|
|
|
110
|
+
next unless condition.class.name.to_s.include?("ActionFilter")
|
|
111
|
+
|
|
112
|
+
key = condition.instance_variable_get(:@conditional_key)
|
|
113
|
+
actions = condition.instance_variable_get(:@actions).to_a
|
|
114
|
+
parts << "#{key}: #{actions.join(", ")}" if actions.any?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
parts.empty? ? "" : " (#{parts.join(", ")})"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docwright
|
|
4
|
+
module Extractors
|
|
5
|
+
class BackgroundJobsExtractor
|
|
6
|
+
INTERNAL_JOBS = %w[ApplicationJob].freeze
|
|
7
|
+
|
|
8
|
+
def generate
|
|
9
|
+
Rails.application.eager_load!
|
|
10
|
+
|
|
11
|
+
jobs = find_jobs
|
|
12
|
+
if jobs.empty?
|
|
13
|
+
puts "DocWright: no background jobs found — skipping background_jobs.md"
|
|
14
|
+
return
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
priorities = load_queue_priorities
|
|
18
|
+
schedules = load_schedules
|
|
19
|
+
|
|
20
|
+
FileUtils.mkdir_p("docs")
|
|
21
|
+
|
|
22
|
+
jobs.each do |job|
|
|
23
|
+
lines = build_job_lines(job, priorities, schedules)
|
|
24
|
+
notes = "#### Notes for #{job.name}\n<!-- Describe what triggers this job, retry behavior, dependencies -->"
|
|
25
|
+
Docwright::Merger.write_named("docs/background_jobs.md", job.name, lines.join("\n"), notes)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
summary_lines = build_summary_lines(jobs, priorities, schedules)
|
|
29
|
+
Docwright::Merger.write_named("docs/background_jobs.md", "_summary", summary_lines.join("\n"), "")
|
|
30
|
+
|
|
31
|
+
puts "DocWright: wrote docs/background_jobs.md"
|
|
32
|
+
rescue NameError => e
|
|
33
|
+
raise unless e.message.include?("ActiveJob")
|
|
34
|
+
|
|
35
|
+
puts "DocWright: skipped background_jobs.md — ActiveJob is not loaded."
|
|
36
|
+
puts " To enable: uncomment 'require \"active_job/railtie\"' in config/application.rb"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def find_jobs
|
|
42
|
+
ActiveJob::Base.descendants
|
|
43
|
+
.reject { |j| INTERNAL_JOBS.include?(j.name) }
|
|
44
|
+
.reject { |j| j.name.nil? }
|
|
45
|
+
.sort_by(&:name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def load_queue_priorities
|
|
49
|
+
path = "config/sidekiq.yml"
|
|
50
|
+
return {} unless File.exist?(path)
|
|
51
|
+
|
|
52
|
+
require "yaml"
|
|
53
|
+
config = YAML.load_file(path)
|
|
54
|
+
return {} unless config.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
queues = config[:queues] || config["queues"] || []
|
|
57
|
+
queues.each_with_object({}) do |entry, hash|
|
|
58
|
+
hash[entry[0].to_s] = entry[1]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def load_schedules
|
|
63
|
+
schedules = {}
|
|
64
|
+
schedules.merge!(load_whenever_schedules)
|
|
65
|
+
schedules.merge!(load_solid_queue_schedules)
|
|
66
|
+
schedules
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def load_whenever_schedules
|
|
70
|
+
path = "config/schedule.rb"
|
|
71
|
+
return {} unless File.exist?(path)
|
|
72
|
+
|
|
73
|
+
schedules = {}
|
|
74
|
+
current_schedule = nil
|
|
75
|
+
File.readlines(path).each do |line|
|
|
76
|
+
if line =~ /every\s+(.+)\s+do/
|
|
77
|
+
current_schedule = ::Regexp.last_match(1).strip
|
|
78
|
+
elsif line =~ /runner\s+["'](.+Job)/
|
|
79
|
+
job_name = ::Regexp.last_match(1).strip
|
|
80
|
+
schedules[job_name] = current_schedule if current_schedule
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
schedules
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def load_solid_queue_schedules
|
|
88
|
+
path = "config/recurring.yml"
|
|
89
|
+
return {} unless File.exist?(path)
|
|
90
|
+
|
|
91
|
+
require "yaml"
|
|
92
|
+
config = YAML.load_file(path)
|
|
93
|
+
return {} unless config.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
config.each_with_object({}) do |(_, v), hash|
|
|
96
|
+
hash[v["class"]] = v["schedule"] if v["class"] && v["schedule"]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_job_lines(job, priorities, schedules)
|
|
101
|
+
queue = job.queue_name.to_s
|
|
102
|
+
priority = priorities[queue]
|
|
103
|
+
queue_display = priority ? "#{queue} (priority: #{priority})" : queue
|
|
104
|
+
schedule = schedules[job.name] || "not scheduled"
|
|
105
|
+
path = "app/jobs/#{job.name.underscore}.rb"
|
|
106
|
+
|
|
107
|
+
lines = []
|
|
108
|
+
lines << "#### #{job.name}"
|
|
109
|
+
lines << "- Queue: #{queue_display}"
|
|
110
|
+
lines << "- Schedule: #{schedule}"
|
|
111
|
+
lines << "- Defined in: #{path}"
|
|
112
|
+
lines
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_summary_lines(jobs, priorities, schedules)
|
|
116
|
+
scheduled = jobs.select { |j| schedules.key?(j.name) }
|
|
117
|
+
unscheduled = jobs.reject { |j| schedules.key?(j.name) }
|
|
118
|
+
queues_used = jobs.map(&:queue_name).map(&:to_s).uniq.sort
|
|
119
|
+
|
|
120
|
+
lines = ["### Background Jobs Summary\n"]
|
|
121
|
+
|
|
122
|
+
unless priorities.empty?
|
|
123
|
+
lines << "### Queue priorities (from config/sidekiq.yml)"
|
|
124
|
+
priorities.each { |q, p| lines << "- #{q} — priority: #{p}" }
|
|
125
|
+
lines << ""
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
unless scheduled.empty?
|
|
129
|
+
lines << "### Scheduled jobs"
|
|
130
|
+
scheduled.each { |j| lines << "- #{j.name} — #{schedules[j.name]}" }
|
|
131
|
+
lines << ""
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
unless unscheduled.empty?
|
|
135
|
+
lines << "### Unscheduled jobs"
|
|
136
|
+
unscheduled.each { |j| lines << "- #{j.name}" }
|
|
137
|
+
lines << ""
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
lines << "### Summary"
|
|
141
|
+
lines << "- Total jobs: #{jobs.size}"
|
|
142
|
+
lines << "- Queues used: #{queues_used.join(", ")}"
|
|
143
|
+
lines << "- Scheduled: #{scheduled.size}"
|
|
144
|
+
lines << "- Unscheduled: #{unscheduled.size}"
|
|
145
|
+
lines
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docwright
|
|
4
|
+
module Extractors
|
|
5
|
+
class ConcernsExtractor
|
|
6
|
+
def generate
|
|
7
|
+
Rails.application.eager_load!
|
|
8
|
+
|
|
9
|
+
concerns = find_model_concerns + find_controller_concerns
|
|
10
|
+
|
|
11
|
+
if concerns.empty?
|
|
12
|
+
puts "DocWright: no concerns found — skipping concerns.md"
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
FileUtils.mkdir_p("docs")
|
|
17
|
+
|
|
18
|
+
concerns.each do |concern|
|
|
19
|
+
lines = build_concern_lines(concern)
|
|
20
|
+
notes = "#### Notes for #{concern[:name]}\n<!-- Describe what this concern adds and why it was extracted -->"
|
|
21
|
+
Docwright::Merger.write_named("docs/concerns.md", concern[:name], lines.join("\n"), notes)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
model_concerns = concerns.count { |c| c[:type] == "Model Concern" }
|
|
25
|
+
controller_concerns = concerns.count { |c| c[:type] == "Controller Concern" }
|
|
26
|
+
|
|
27
|
+
summary_lines = [
|
|
28
|
+
"### Summary",
|
|
29
|
+
"- Total concerns: #{concerns.size}",
|
|
30
|
+
"- Model concerns: #{model_concerns}",
|
|
31
|
+
"- Controller concerns: #{controller_concerns}"
|
|
32
|
+
]
|
|
33
|
+
Docwright::Merger.write_named("docs/concerns.md", "_summary", summary_lines.join("\n"), "")
|
|
34
|
+
|
|
35
|
+
puts "DocWright: wrote docs/concerns.md"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def find_model_concerns
|
|
41
|
+
find_concerns("app/models/concerns", "Model Concern", ActiveRecord::Base)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def find_controller_concerns
|
|
45
|
+
find_concerns("app/controllers/concerns", "Controller Concern", ActionController::Base)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def find_concerns(path, type, base_class)
|
|
49
|
+
return [] unless Dir.exist?(path)
|
|
50
|
+
|
|
51
|
+
Dir.glob("#{path}/**/*.rb").filter_map do |file|
|
|
52
|
+
class_name = file.gsub("#{path}/", "")
|
|
53
|
+
.gsub(".rb", "")
|
|
54
|
+
.camelize
|
|
55
|
+
|
|
56
|
+
concern = Object.const_get(class_name)
|
|
57
|
+
methods = concern.public_instance_methods(false).map(&:to_s).sort
|
|
58
|
+
included_in = find_included_in(concern, base_class)
|
|
59
|
+
|
|
60
|
+
{ name: class_name, type: type, path: file, methods: methods, included_in: included_in }
|
|
61
|
+
rescue NameError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def find_included_in(concern, base_class)
|
|
67
|
+
base_class.descendants
|
|
68
|
+
.select { |klass| klass.ancestors.include?(concern) }
|
|
69
|
+
.map(&:name)
|
|
70
|
+
.compact
|
|
71
|
+
.sort
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_concern_lines(concern)
|
|
75
|
+
lines = []
|
|
76
|
+
lines << "#### #{concern[:name]}"
|
|
77
|
+
lines << "- Type: #{concern[:type]}"
|
|
78
|
+
lines << "- Location: #{concern[:path]}"
|
|
79
|
+
lines << if concern[:methods].any?
|
|
80
|
+
"- Methods: #{concern[:methods].join(", ")}"
|
|
81
|
+
else
|
|
82
|
+
"- Methods: none"
|
|
83
|
+
end
|
|
84
|
+
lines << if concern[:included_in].any?
|
|
85
|
+
"- Included in: #{concern[:included_in].join(", ")}"
|
|
86
|
+
else
|
|
87
|
+
"- Included in: none detected"
|
|
88
|
+
end
|
|
89
|
+
lines
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docwright
|
|
4
|
+
module Extractors
|
|
5
|
+
class DatabaseExtractor
|
|
6
|
+
def generate
|
|
7
|
+
skip = %w[schema_migrations ar_internal_metadata]
|
|
8
|
+
tables = ActiveRecord::Base.connection.tables.reject { |t| skip.include?(t) }.sort
|
|
9
|
+
lines = ["# Database Documentation\n"]
|
|
10
|
+
|
|
11
|
+
tables.each do |table|
|
|
12
|
+
lines << "## #{table}\n"
|
|
13
|
+
columns = ActiveRecord::Base.connection.columns(table)
|
|
14
|
+
columns.each do |col|
|
|
15
|
+
lines << "- **#{col.name}** (#{col.type})"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
lines << ""
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
FileUtils.mkdir_p("docs")
|
|
22
|
+
Docwright::Merger.write("docs/database.md", lines.join("\n"))
|
|
23
|
+
puts "DocWright: Wrote docs/database.md"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docwright
|
|
4
|
+
module Extractors
|
|
5
|
+
class ModelExtractor
|
|
6
|
+
def generate
|
|
7
|
+
models = find_models
|
|
8
|
+
FileUtils.mkdir_p("docs")
|
|
9
|
+
|
|
10
|
+
models.each do |model|
|
|
11
|
+
lines = []
|
|
12
|
+
lines << "## #{model.name}"
|
|
13
|
+
lines << "**Table:** #{model.table_name}\n"
|
|
14
|
+
|
|
15
|
+
lines << "### Associations"
|
|
16
|
+
associations = model.reflect_on_all_associations
|
|
17
|
+
if associations.empty?
|
|
18
|
+
lines << "- None"
|
|
19
|
+
else
|
|
20
|
+
associations.each do |a|
|
|
21
|
+
lines << "- #{a.macro} :#{a.name}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
lines << "\n### Validations"
|
|
26
|
+
validations = model.validators
|
|
27
|
+
if validations.empty?
|
|
28
|
+
lines << "- None"
|
|
29
|
+
else
|
|
30
|
+
validations.each do |v|
|
|
31
|
+
lines << "- #{v.class.name} on : #{v.attributes.join(", ")}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
notes = "### Notes for #{model.name}\n<!-- Add your notes about #{model.name} here -->"
|
|
36
|
+
Docwright::Merger.write_named("docs/models.md", model.name, lines.join("\n"), notes)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
puts "DocWright: wrote docs/models.md"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def find_models
|
|
45
|
+
Rails.application.eager_load!
|
|
46
|
+
ActiveRecord::Base.descendants.reject { |m| m.abstract_class? }.sort_by(&:name)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docwright
|
|
4
|
+
module Extractors
|
|
5
|
+
class ServicesExtractor
|
|
6
|
+
def generate
|
|
7
|
+
Rails.application.eager_load!
|
|
8
|
+
|
|
9
|
+
services = find_services
|
|
10
|
+
if services.empty?
|
|
11
|
+
puts "DocWright: no services found — skipping services.md"
|
|
12
|
+
return
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
FileUtils.mkdir_p("docs")
|
|
16
|
+
services.each do |service|
|
|
17
|
+
lines = build_service_lines(service)
|
|
18
|
+
notes = "#### Notes for #{service[:name]}\n<!-- Describe when to use this service and what it does -->"
|
|
19
|
+
Docwright::Merger.write_named("docs/services.md", service[:name], lines.join("\n"), notes)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
summary_lines = ["### Summary", "- Total services: #{services.size}"]
|
|
23
|
+
Docwright::Merger.write_named("docs/services.md", "_summary", summary_lines.join("\n"), "")
|
|
24
|
+
|
|
25
|
+
puts "DocWright: wrote docs/services.md"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def find_services # rubocop:disable Metrics/MethodLength
|
|
31
|
+
return [] unless Dir.exist?("app/services")
|
|
32
|
+
|
|
33
|
+
Dir.glob("app/services/**/*.rb").filter_map do |path|
|
|
34
|
+
class_name = path.gsub("app/services/", "")
|
|
35
|
+
.gsub(".rb", "")
|
|
36
|
+
.camelize
|
|
37
|
+
|
|
38
|
+
klass = Object.const_get(class_name)
|
|
39
|
+
methods = klass.public_instance_methods(false).map(&:to_s).sort
|
|
40
|
+
|
|
41
|
+
{ name: class_name, path: path, methods: methods }
|
|
42
|
+
rescue NameError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_service_lines(service)
|
|
48
|
+
lines = []
|
|
49
|
+
lines << "#### #{service[:name]}"
|
|
50
|
+
lines << "- Location: #{service[:path]}"
|
|
51
|
+
lines << if service[:methods].any?
|
|
52
|
+
"- Public methods: #{service[:methods].join(", ")}"
|
|
53
|
+
else
|
|
54
|
+
"- Public methods: none"
|
|
55
|
+
end
|
|
56
|
+
lines
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Docwright
|
|
6
|
+
module Generators
|
|
7
|
+
class FeatureGenerator
|
|
8
|
+
CONFIG_FILE = ".docwright.yml"
|
|
9
|
+
|
|
10
|
+
def generate
|
|
11
|
+
return unless File.exist?(CONFIG_FILE)
|
|
12
|
+
|
|
13
|
+
config = YAML.load_file(CONFIG_FILE)
|
|
14
|
+
features = config["features"] || []
|
|
15
|
+
return if features.empty?
|
|
16
|
+
|
|
17
|
+
FileUtils.mkdir_p("docs/features")
|
|
18
|
+
|
|
19
|
+
features.each do |feature|
|
|
20
|
+
name = feature["name"]
|
|
21
|
+
description = feature["description"]
|
|
22
|
+
path = "docs/features/#{name}.md"
|
|
23
|
+
|
|
24
|
+
if File.exist?(path)
|
|
25
|
+
puts "DocWright: skipped #{path} (already exists)"
|
|
26
|
+
else
|
|
27
|
+
File.write(path, template(name, description))
|
|
28
|
+
puts "DocWright: wrote #{path}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def template(name, description)
|
|
36
|
+
<<~MD
|
|
37
|
+
# #{name.gsub("_", " ").capitalize}
|
|
38
|
+
|
|
39
|
+
#{description}
|
|
40
|
+
|
|
41
|
+
## Overview
|
|
42
|
+
<!-- Describe this feature at a high level -->
|
|
43
|
+
|
|
44
|
+
## Models involved
|
|
45
|
+
<!-- Auto-detection coming in a future phase -->
|
|
46
|
+
|
|
47
|
+
## Controllers and actions
|
|
48
|
+
<!-- Auto-detection coming in a future phase -->
|
|
49
|
+
|
|
50
|
+
## Services
|
|
51
|
+
<!-- Auto-detection coming in a future phase -->
|
|
52
|
+
|
|
53
|
+
## Background jobs
|
|
54
|
+
<!-- Auto-detection coming in a future phase -->
|
|
55
|
+
|
|
56
|
+
## Views
|
|
57
|
+
<!-- Auto-detection coming in a future phase -->
|
|
58
|
+
|
|
59
|
+
## User flows
|
|
60
|
+
|
|
61
|
+
### Happy path
|
|
62
|
+
<!-- Describe the main successful flow step by step -->
|
|
63
|
+
<!-- Example: User > sidebar > article list > click create > fill form > submit -->
|
|
64
|
+
|
|
65
|
+
### Alternative paths
|
|
66
|
+
<!-- Describe edge cases, error states, or alternative routes -->
|
|
67
|
+
|
|
68
|
+
## Edge cases
|
|
69
|
+
<!-- Document important edge cases for this feature -->
|
|
70
|
+
MD
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|