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.
@@ -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