railsmith 1.0.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/.tool-versions +1 -0
- data/CHANGELOG.md +64 -0
- data/LICENSE.txt +21 -0
- data/MIGRATION.md +156 -0
- data/README.md +249 -0
- data/Rakefile +14 -0
- data/docs/cookbook.md +605 -0
- data/docs/legacy-adoption.md +283 -0
- data/docs/quickstart.md +110 -0
- data/lib/generators/railsmith/domain/domain_generator.rb +57 -0
- data/lib/generators/railsmith/domain/templates/domain.rb.tt +14 -0
- data/lib/generators/railsmith/install/install_generator.rb +21 -0
- data/lib/generators/railsmith/install/templates/railsmith.rb +10 -0
- data/lib/generators/railsmith/model_service/model_service_generator.rb +121 -0
- data/lib/generators/railsmith/model_service/templates/model_service.rb.tt +28 -0
- data/lib/generators/railsmith/operation/operation_generator.rb +88 -0
- data/lib/generators/railsmith/operation/templates/operation.rb.tt +27 -0
- data/lib/railsmith/arch_checks/cli.rb +79 -0
- data/lib/railsmith/arch_checks/direct_model_access_checker.rb +94 -0
- data/lib/railsmith/arch_checks/missing_service_usage_checker.rb +206 -0
- data/lib/railsmith/arch_checks/violation.rb +14 -0
- data/lib/railsmith/arch_checks.rb +7 -0
- data/lib/railsmith/arch_report.rb +96 -0
- data/lib/railsmith/base_service/bulk_actions.rb +77 -0
- data/lib/railsmith/base_service/bulk_contract.rb +56 -0
- data/lib/railsmith/base_service/bulk_execution.rb +68 -0
- data/lib/railsmith/base_service/bulk_params.rb +56 -0
- data/lib/railsmith/base_service/crud_actions.rb +63 -0
- data/lib/railsmith/base_service/crud_error_mapping.rb +78 -0
- data/lib/railsmith/base_service/crud_model_resolution.rb +36 -0
- data/lib/railsmith/base_service/crud_record_helpers.rb +60 -0
- data/lib/railsmith/base_service/crud_transactions.rb +31 -0
- data/lib/railsmith/base_service/domain_context_propagation.rb +29 -0
- data/lib/railsmith/base_service/dup_helpers.rb +15 -0
- data/lib/railsmith/base_service/validation.rb +67 -0
- data/lib/railsmith/base_service.rb +96 -0
- data/lib/railsmith/configuration.rb +18 -0
- data/lib/railsmith/cross_domain_guard.rb +90 -0
- data/lib/railsmith/cross_domain_warning_formatter.rb +66 -0
- data/lib/railsmith/deep_dup.rb +20 -0
- data/lib/railsmith/domain_context.rb +44 -0
- data/lib/railsmith/errors.rb +50 -0
- data/lib/railsmith/instrumentation.rb +64 -0
- data/lib/railsmith/railtie.rb +10 -0
- data/lib/railsmith/result.rb +60 -0
- data/lib/railsmith/version.rb +5 -0
- data/lib/railsmith.rb +31 -0
- data/lib/tasks/railsmith.rake +24 -0
- data/sig/railsmith.rbs +4 -0
- metadata +116 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
5
|
+
|
|
6
|
+
module Railsmith
|
|
7
|
+
module Generators
|
|
8
|
+
# Scaffolds a domain operation class that returns `Railsmith::Result`.
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
# - rails g railsmith:operation Billing::Invoices::Create
|
|
12
|
+
# -> Billing::Operations::Invoices::Create
|
|
13
|
+
#
|
|
14
|
+
# Namespaced domain:
|
|
15
|
+
# - rails g railsmith:operation Admin::Billing::Invoices::Create --domain=Admin::Billing
|
|
16
|
+
class OperationGenerator < Rails::Generators::NamedBase
|
|
17
|
+
source_root File.expand_path("templates", __dir__)
|
|
18
|
+
|
|
19
|
+
class_option :domains_path,
|
|
20
|
+
type: :string,
|
|
21
|
+
default: "app/domains",
|
|
22
|
+
desc: "Base path where domains live"
|
|
23
|
+
|
|
24
|
+
class_option :domain,
|
|
25
|
+
type: :string,
|
|
26
|
+
default: nil,
|
|
27
|
+
desc: "Domain module for namespaced domains (e.g. Admin::Billing)"
|
|
28
|
+
|
|
29
|
+
def create_operation
|
|
30
|
+
relative_target = File.join(options.fetch(:domains_path), target_file)
|
|
31
|
+
return if skip_existing_file?(relative_target)
|
|
32
|
+
|
|
33
|
+
empty_directory File.dirname(File.join(destination_root, relative_target))
|
|
34
|
+
template "operation.rb.tt", relative_target
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def skip_existing_file?(relative_path)
|
|
40
|
+
absolute = File.join(destination_root, relative_path)
|
|
41
|
+
return false unless File.exist?(absolute)
|
|
42
|
+
return false if options[:force]
|
|
43
|
+
|
|
44
|
+
say_status(
|
|
45
|
+
:skip,
|
|
46
|
+
"#{relative_path} already exists (use --force to overwrite)",
|
|
47
|
+
:yellow
|
|
48
|
+
)
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def domain_modules
|
|
53
|
+
explicit = options[:domain].to_s.strip
|
|
54
|
+
return explicit.split("::") unless explicit.empty?
|
|
55
|
+
|
|
56
|
+
[class_name.split("::").first]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def operation_modules
|
|
60
|
+
parts = class_name.split("::")
|
|
61
|
+
return [] if parts.length < 2
|
|
62
|
+
|
|
63
|
+
remaining = parts.drop(domain_modules.length)
|
|
64
|
+
remaining[0...-1]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def operation_class_name
|
|
68
|
+
class_name.split("::").last
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def target_file
|
|
72
|
+
File.join(domain_file_path, "operations", *operation_file_segments, "#{file_name}.rb")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def domain_file_path
|
|
76
|
+
domain_modules.map(&:underscore).join("/")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def operation_file_segments
|
|
80
|
+
operation_modules.map(&:underscore)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def declared_modules
|
|
84
|
+
domain_modules + ["Operations"] + operation_modules
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
<% declared_modules.each do |mod| -%>
|
|
4
|
+
module <%= mod %>
|
|
5
|
+
<% end -%>
|
|
6
|
+
class <%= operation_class_name %>
|
|
7
|
+
def self.call(params: {}, context: {})
|
|
8
|
+
new(params:, context:).call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
attr_reader :params, :context
|
|
12
|
+
|
|
13
|
+
def initialize(params:, context:)
|
|
14
|
+
@params = Railsmith.deep_dup(params || {})
|
|
15
|
+
@context = Railsmith.deep_dup(context || {})
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
current_domain = Railsmith::DomainContext.normalize_current_domain(context[:current_domain])
|
|
20
|
+
|
|
21
|
+
Railsmith::Result.success(value: { current_domain: current_domain })
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
<% (declared_modules.length - 1).downto(0) do -%>
|
|
25
|
+
end
|
|
26
|
+
<% end -%>
|
|
27
|
+
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
module ArchChecks
|
|
5
|
+
# Runs architecture checks driven by ENV and prints a report to +output+.
|
|
6
|
+
# Returns 0 when the scan is clean or warn-only mode allows violations, and 1
|
|
7
|
+
# when fail-on is enabled and violations exist.
|
|
8
|
+
class Cli
|
|
9
|
+
def self.run(env: ENV, output: $stdout, warn_proc: Kernel.method(:warn))
|
|
10
|
+
new(env: env, output: output, warn_proc: warn_proc).run
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(env:, output:, warn_proc:)
|
|
14
|
+
@env = env
|
|
15
|
+
@output = output
|
|
16
|
+
@warn_proc = warn_proc
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Integer] 0 or 1
|
|
20
|
+
def run
|
|
21
|
+
format_sym = normalized_format
|
|
22
|
+
paths = paths_list
|
|
23
|
+
fail_on = fail_on_violations?
|
|
24
|
+
checked_files, violations = scan(paths)
|
|
25
|
+
report = Railsmith::ArchReport.new(violations: violations, checked_files: checked_files)
|
|
26
|
+
emit_report(format_sym, report)
|
|
27
|
+
status_for(fail_on, report)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def normalized_format
|
|
33
|
+
raw = @env.fetch("RAILSMITH_FORMAT", "text").downcase.strip
|
|
34
|
+
unless %w[text json].include?(raw)
|
|
35
|
+
@warn_proc.call("railsmith:arch_check — invalid RAILSMITH_FORMAT=#{raw.inspect}, using text")
|
|
36
|
+
raw = "text"
|
|
37
|
+
end
|
|
38
|
+
raw.to_sym
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def paths_list
|
|
42
|
+
@env.fetch("RAILSMITH_PATHS", "app/controllers").split(",").map(&:strip)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fail_on_violations?
|
|
46
|
+
strict = @env.fetch("RAILSMITH_FAIL_ON_ARCH_VIOLATIONS", "").strip.downcase
|
|
47
|
+
if %w[true 1 yes].include?(strict)
|
|
48
|
+
true
|
|
49
|
+
elsif strict.empty?
|
|
50
|
+
Railsmith.configuration.fail_on_arch_violations
|
|
51
|
+
else
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def scan(paths)
|
|
57
|
+
checkers = [
|
|
58
|
+
Railsmith::ArchChecks::DirectModelAccessChecker.new,
|
|
59
|
+
Railsmith::ArchChecks::MissingServiceUsageChecker.new
|
|
60
|
+
]
|
|
61
|
+
paths.each_with_object([[], []]) do |path, (checked_files, violations)|
|
|
62
|
+
next unless Dir.exist?(path)
|
|
63
|
+
|
|
64
|
+
checked_files.concat(Dir.glob(File.join(path, "**", "*_controller.rb")))
|
|
65
|
+
checkers.each { |checker| violations.concat(checker.check(path: path)) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def emit_report(format_sym, report)
|
|
70
|
+
out_string = format_sym == :json ? report.as_json : report.as_text
|
|
71
|
+
@output.puts out_string
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def status_for(fail_on, report)
|
|
75
|
+
fail_on && !report.clean? ? 1 : 0
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
module ArchChecks
|
|
5
|
+
# Scans controller source files for direct ActiveRecord model access.
|
|
6
|
+
#
|
|
7
|
+
# Flags lines like +User.find(params[:id])+ or +Post.where(active: true)+
|
|
8
|
+
# that bypass the service layer. The check is heuristic: it detects
|
|
9
|
+
# CamelCase class names followed by common AR query/persistence methods,
|
|
10
|
+
# excluding well-known non-model classes (Rails, Time, JSON, etc.).
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# checker = Railsmith::ArchChecks::DirectModelAccessChecker.new
|
|
14
|
+
# violations = checker.check(path: "app/controllers")
|
|
15
|
+
class DirectModelAccessChecker
|
|
16
|
+
AR_METHODS = %w[
|
|
17
|
+
find find_by find_by! find_or_create_by find_or_initialize_by
|
|
18
|
+
where order select limit offset joins includes eager_load preload
|
|
19
|
+
all first last count sum average minimum maximum
|
|
20
|
+
create create! update update_all destroy destroy_all delete delete_all
|
|
21
|
+
exists? any? none? many? pluck ids
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
# Well-known non-model CamelCase roots frequently seen in controllers.
|
|
25
|
+
NON_MODEL_CLASSES = %w[
|
|
26
|
+
Rails I18n Time Date DateTime ActiveRecord ApplicationRecord ActiveModel
|
|
27
|
+
ActionController ApplicationController ActionDispatch AbstractController
|
|
28
|
+
ActionView ActionMailer ActiveJob ActiveSupport ActiveStorage
|
|
29
|
+
Integer Float String Hash Array Symbol Numeric BigDecimal
|
|
30
|
+
File Dir IO URI URL Net HTTP JSON YAML CSV
|
|
31
|
+
Logger Thread Fiber Mutex Proc Method Class Module Object BasicObject
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
AR_METHODS_PATTERN = AR_METHODS.map { |m| Regexp.escape(m) }.join("|")
|
|
35
|
+
|
|
36
|
+
# Matches: CamelCase class name, dot, AR method, not followed by identifier chars.
|
|
37
|
+
# The negative lookahead `(?=[^a-zA-Z0-9_]|$)` prevents `find` matching `finder`.
|
|
38
|
+
DETECT_RE = /
|
|
39
|
+
\b
|
|
40
|
+
([A-Z][A-Za-z0-9]*(?:::[A-Z][A-Za-z0-9]*)*) (?# class name, possibly namespaced)
|
|
41
|
+
\.
|
|
42
|
+
(#{AR_METHODS_PATTERN}) (?# AR method)
|
|
43
|
+
(?=[^a-zA-Z0-9_]|$) (?# not followed by identifier chars)
|
|
44
|
+
/x
|
|
45
|
+
|
|
46
|
+
# @param path [String] directory to scan (recursively for *_controller.rb files)
|
|
47
|
+
# @return [Array<Violation>]
|
|
48
|
+
def check(path:)
|
|
49
|
+
Dir.glob(File.join(path, "**", "*_controller.rb"))
|
|
50
|
+
.flat_map { |file| check_file(file) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param file [String] path to a single Ruby source file
|
|
54
|
+
# @return [Array<Violation>]
|
|
55
|
+
def check_file(file)
|
|
56
|
+
violations = []
|
|
57
|
+
File.foreach(file).with_index(1) do |raw_line, lineno|
|
|
58
|
+
line = raw_line.strip
|
|
59
|
+
violations.concat(line_violations(file, lineno, line)) unless comment_line?(line)
|
|
60
|
+
end
|
|
61
|
+
violations
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def line_violations(file, lineno, line)
|
|
67
|
+
line.scan(DETECT_RE).filter_map do |class_name, method_name|
|
|
68
|
+
next if excluded_class?(class_name)
|
|
69
|
+
|
|
70
|
+
build_violation(file, lineno, class_name, method_name)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_violation(file, lineno, class_name, method_name)
|
|
75
|
+
Violation.new(
|
|
76
|
+
:direct_model_access,
|
|
77
|
+
file,
|
|
78
|
+
lineno,
|
|
79
|
+
"Direct model access: `#{class_name}.#{method_name}` — route through a service instead",
|
|
80
|
+
:warn
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def comment_line?(stripped)
|
|
85
|
+
stripped.start_with?("#")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def excluded_class?(class_name)
|
|
89
|
+
root = class_name.split("::").first
|
|
90
|
+
NON_MODEL_CLASSES.include?(root)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
module ArchChecks
|
|
5
|
+
# Extracts public instance methods from a controller source line list using indentation.
|
|
6
|
+
module ControllerActionMethodExtractor
|
|
7
|
+
class << self
|
|
8
|
+
# @param lines [Array<String>]
|
|
9
|
+
# @return [Array<Hash>] each hash has :name, :start (line), :indent, :body (String lines)
|
|
10
|
+
def extract(lines)
|
|
11
|
+
methods = []
|
|
12
|
+
state = initial_state
|
|
13
|
+
|
|
14
|
+
lines.each_with_index do |raw, idx|
|
|
15
|
+
process_line(raw, idx + 1, state, methods)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
methods
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def initial_state
|
|
24
|
+
{
|
|
25
|
+
current: nil,
|
|
26
|
+
method_indent: nil,
|
|
27
|
+
class_method_indent: nil,
|
|
28
|
+
private_section: false
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def process_line(raw, lineno, state, methods)
|
|
33
|
+
indent = raw.length - raw.lstrip.length
|
|
34
|
+
stripped = raw.strip
|
|
35
|
+
|
|
36
|
+
return if stripped.empty? || stripped.start_with?("#")
|
|
37
|
+
|
|
38
|
+
detect_class_method_indent(stripped, indent, state)
|
|
39
|
+
return if visibility_keyword_consumed?(stripped, indent, state)
|
|
40
|
+
|
|
41
|
+
handle_def_or_body(stripped, indent, lineno, state, methods)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def detect_class_method_indent(stripped, indent, state)
|
|
45
|
+
return unless state[:current].nil? && state[:class_method_indent].nil?
|
|
46
|
+
return unless def_or_visibility_start?(stripped)
|
|
47
|
+
|
|
48
|
+
state[:class_method_indent] = indent
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def def_or_visibility_start?(stripped)
|
|
52
|
+
stripped.match?(/\Adef\s+[a-z_]/) || visibility_keyword_line?(stripped)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Boolean] true if this line was a bare visibility keyword (no further processing)
|
|
56
|
+
def visibility_keyword_consumed?(stripped, indent, state)
|
|
57
|
+
return false unless at_class_visibility_indent?(state, indent)
|
|
58
|
+
|
|
59
|
+
consume_visibility_keyword?(stripped, state)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def at_class_visibility_indent?(state, indent)
|
|
63
|
+
state[:current].nil? && state[:class_method_indent] && indent == state[:class_method_indent]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def consume_visibility_keyword?(stripped, state)
|
|
67
|
+
if visibility_private_line?(stripped)
|
|
68
|
+
state[:private_section] = true
|
|
69
|
+
true
|
|
70
|
+
elsif visibility_public_line?(stripped)
|
|
71
|
+
state[:private_section] = false
|
|
72
|
+
true
|
|
73
|
+
else
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def handle_def_or_body(stripped, indent, lineno, state, methods)
|
|
79
|
+
if state[:current].nil? && (m = stripped.match(/\Adef\s+([a-z_]\w*)/))
|
|
80
|
+
start_method(m, indent, lineno, state) unless state[:private_section]
|
|
81
|
+
elsif state[:current]
|
|
82
|
+
continue_or_close_method(stripped, indent, lineno, state, methods)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def start_method(match, indent, lineno, state)
|
|
87
|
+
state[:current] = { name: match[1], start: lineno, indent: indent, body: [] }
|
|
88
|
+
state[:method_indent] = indent
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def continue_or_close_method(stripped, indent, lineno, state, methods)
|
|
92
|
+
if method_close_line?(stripped) && indent == state[:method_indent]
|
|
93
|
+
state[:current][:end] = lineno
|
|
94
|
+
methods << state[:current]
|
|
95
|
+
state[:current] = nil
|
|
96
|
+
state[:method_indent] = nil
|
|
97
|
+
else
|
|
98
|
+
state[:current][:body] << stripped
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def method_close_line?(stripped)
|
|
103
|
+
stripped.match?(/\Aend(?:\s*#.*)?$/)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def visibility_keyword_line?(stripped)
|
|
107
|
+
visibility_private_line?(stripped) || visibility_public_line?(stripped)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def visibility_private_line?(stripped)
|
|
111
|
+
stripped.match?(/\A(private|protected)(?:\s+#.*)?\z/)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def visibility_public_line?(stripped)
|
|
115
|
+
stripped.match?(/\Apublic(?:\s+#.*)?\z/)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Scans public controller action methods for model access without service or operation delegation.
|
|
121
|
+
#
|
|
122
|
+
# Flags methods that call ActiveRecord methods directly but contain no reference
|
|
123
|
+
# to a +*Service+ / +*Operation+ entrypoint (+.new+ / +.call+), or a +::Operations::...+ class
|
|
124
|
+
# (+.call+ / +.new+), indicating the data-layer interaction has not been routed through the layer.
|
|
125
|
+
#
|
|
126
|
+
# Method boundaries use standard 2-space Ruby indentation (RuboCop default).
|
|
127
|
+
# Methods after a bare +private+ / +protected+ keyword are skipped. Single-line and endless
|
|
128
|
+
# method definitions are out of scope for this checker.
|
|
129
|
+
#
|
|
130
|
+
# Usage:
|
|
131
|
+
# checker = Railsmith::ArchChecks::MissingServiceUsageChecker.new
|
|
132
|
+
# violations = checker.check(path: "app/controllers")
|
|
133
|
+
class MissingServiceUsageChecker
|
|
134
|
+
# *Service / *Operation entrypoints (namespaced tail allowed).
|
|
135
|
+
SERVICE_OR_OPERATION_RE = /
|
|
136
|
+
\b
|
|
137
|
+
(?:[A-Z][A-Za-z0-9]*::)*
|
|
138
|
+
[A-Z][A-Za-z0-9]*(?:Service|Operation)
|
|
139
|
+
(?:::[A-Z][A-Za-z0-9]*)*
|
|
140
|
+
\.(?:new|call)
|
|
141
|
+
\b
|
|
142
|
+
/x
|
|
143
|
+
|
|
144
|
+
# Domain operations from the generator live under +...+::Operations::...+ (e.g. +Billing::Operations::Invoices::Create.call+).
|
|
145
|
+
DOMAIN_OPERATION_RE = /
|
|
146
|
+
\b
|
|
147
|
+
(?:[A-Z][A-Za-z0-9]*::)+
|
|
148
|
+
Operations::
|
|
149
|
+
(?:[A-Z][A-Za-z0-9]*::)*
|
|
150
|
+
[A-Z][A-Za-z0-9]*
|
|
151
|
+
\.(?:new|call)
|
|
152
|
+
\b
|
|
153
|
+
/x
|
|
154
|
+
|
|
155
|
+
# @param path [String] directory to scan (recursively for *_controller.rb files)
|
|
156
|
+
# @return [Array<Violation>]
|
|
157
|
+
def check(path:)
|
|
158
|
+
Dir.glob(File.join(path, "**", "*_controller.rb"))
|
|
159
|
+
.flat_map { |file| check_file(file) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @param file [String] path to a single Ruby source file
|
|
163
|
+
# @return [Array<Violation>]
|
|
164
|
+
def check_file(file)
|
|
165
|
+
lines = File.readlines(file, chomp: true)
|
|
166
|
+
extract_action_methods(lines).filter_map { |method| method_violation(file, method) }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def method_violation(file, method)
|
|
172
|
+
return if method_uses_service?(method)
|
|
173
|
+
return unless method_accesses_model?(method)
|
|
174
|
+
|
|
175
|
+
Violation.new(
|
|
176
|
+
:missing_service_usage,
|
|
177
|
+
file,
|
|
178
|
+
method[:start],
|
|
179
|
+
"Action `#{method[:name]}` accesses models without delegating to a service class",
|
|
180
|
+
:warn
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def extract_action_methods(lines)
|
|
185
|
+
ControllerActionMethodExtractor.extract(lines)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def method_uses_service?(method)
|
|
189
|
+
method[:body].any? do |line|
|
|
190
|
+
SERVICE_OR_OPERATION_RE.match?(line) || DOMAIN_OPERATION_RE.match?(line)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def method_accesses_model?(method)
|
|
195
|
+
method[:body].any? do |line|
|
|
196
|
+
next false if line.start_with?("#")
|
|
197
|
+
|
|
198
|
+
line.scan(DirectModelAccessChecker::DETECT_RE).any? do |class_name, _method_name|
|
|
199
|
+
root = class_name.split("::").first
|
|
200
|
+
!DirectModelAccessChecker::NON_MODEL_CLASSES.include?(root)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
module ArchChecks
|
|
5
|
+
# A single architecture rule violation found by static analysis.
|
|
6
|
+
#
|
|
7
|
+
# @!attribute rule [r] Symbol identifying the detector rule (e.g. +:direct_model_access+).
|
|
8
|
+
# @!attribute file [r] Path to the source file containing the violation.
|
|
9
|
+
# @!attribute line [r] 1-based line number of the offending code.
|
|
10
|
+
# @!attribute message [r] Human-readable description of the violation.
|
|
11
|
+
# @!attribute severity [r] +:warn+ (default, non-blocking) or +:error+.
|
|
12
|
+
Violation = Struct.new(:rule, :file, :line, :message, :severity)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "arch_checks/violation"
|
|
4
|
+
require_relative "arch_checks/direct_model_access_checker"
|
|
5
|
+
require_relative "arch_checks/missing_service_usage_checker"
|
|
6
|
+
require_relative "arch_report"
|
|
7
|
+
require_relative "arch_checks/cli"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Railsmith
|
|
6
|
+
# Formats static-analysis violations for human and machine consumption.
|
|
7
|
+
#
|
|
8
|
+
# Supports two output formats:
|
|
9
|
+
# - +as_text+ — multi-line, human-readable report for local runs.
|
|
10
|
+
# - +as_json+ — single JSON object for CI tooling and log aggregation.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# report = Railsmith::ArchReport.new(violations: violations, checked_files: files)
|
|
14
|
+
# puts report.as_text
|
|
15
|
+
# # or
|
|
16
|
+
# puts report.as_json
|
|
17
|
+
class ArchReport
|
|
18
|
+
SEPARATOR = ("=" * 30).freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :violations, :checked_files
|
|
21
|
+
|
|
22
|
+
# @param violations [Array<ArchChecks::Violation>]
|
|
23
|
+
# @param checked_files [Array<String>] paths that were analysed
|
|
24
|
+
def initialize(violations:, checked_files: [])
|
|
25
|
+
@violations = Array(violations)
|
|
26
|
+
@checked_files = Array(checked_files)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Boolean] true when no violations were found
|
|
30
|
+
def clean?
|
|
31
|
+
violations.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Integer]
|
|
35
|
+
def violation_count
|
|
36
|
+
violations.size
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Multi-line, human-readable text report.
|
|
40
|
+
# @return [String]
|
|
41
|
+
def as_text
|
|
42
|
+
lines = ["Railsmith Architecture Check", SEPARATOR, summary_line]
|
|
43
|
+
unless violations.empty?
|
|
44
|
+
lines << ""
|
|
45
|
+
violations.each { |v| lines.concat(violation_lines(v)) }
|
|
46
|
+
lines << ""
|
|
47
|
+
end
|
|
48
|
+
lines << footer_line
|
|
49
|
+
lines.join("\n")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Single JSON object suitable for CI log parsing.
|
|
53
|
+
# @return [String]
|
|
54
|
+
def as_json
|
|
55
|
+
JSON.generate(to_h)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Hash]
|
|
59
|
+
def to_h
|
|
60
|
+
{ summary: summary_hash, violations: violations.map { |v| violation_to_h(v) } }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def summary_hash
|
|
66
|
+
{ checked_files: checked_files.size, violation_count: violations.size, clean: clean? }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def violation_to_h(violation)
|
|
70
|
+
{
|
|
71
|
+
rule: violation.rule.to_s,
|
|
72
|
+
file: violation.file,
|
|
73
|
+
line: violation.line,
|
|
74
|
+
message: violation.message,
|
|
75
|
+
severity: violation.severity.to_s
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def summary_line
|
|
80
|
+
file_word = checked_files.size == 1 ? "file" : "files"
|
|
81
|
+
violation_word = violations.size == 1 ? "violation" : "violations"
|
|
82
|
+
"Checked #{checked_files.size} #{file_word} — #{violations.size} #{violation_word} found"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def footer_line
|
|
86
|
+
clean? ? "OK — no violations found." : "Violations listed above are warnings only (warn-only mode)."
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def violation_lines(violation)
|
|
90
|
+
[
|
|
91
|
+
" #{violation.file}:#{violation.line}",
|
|
92
|
+
" [#{violation.severity.to_s.upcase}] #{violation.rule}: #{violation.message}"
|
|
93
|
+
]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
class BaseService
|
|
5
|
+
# Default bulk action implementations.
|
|
6
|
+
# @api private
|
|
7
|
+
module BulkActions
|
|
8
|
+
include BulkParams
|
|
9
|
+
include BulkExecution
|
|
10
|
+
include BulkContract
|
|
11
|
+
|
|
12
|
+
def bulk_create
|
|
13
|
+
model_klass = model_class
|
|
14
|
+
return missing_model_class_result unless model_klass
|
|
15
|
+
|
|
16
|
+
bulk_write_operation(model_klass, operation: :bulk_create) do |attributes|
|
|
17
|
+
record = build_record(model_klass, sanitize_attributes(attributes || {}))
|
|
18
|
+
persist_write(record, method_name: :save)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def bulk_update
|
|
23
|
+
model_klass = model_class
|
|
24
|
+
return missing_model_class_result unless model_klass
|
|
25
|
+
|
|
26
|
+
bulk_write_operation(model_klass, operation: :bulk_update) do |item|
|
|
27
|
+
bulk_update_one(model_klass, item)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def bulk_destroy
|
|
32
|
+
model_klass = model_class
|
|
33
|
+
return missing_model_class_result unless model_klass
|
|
34
|
+
|
|
35
|
+
bulk_write_operation(model_klass, operation: :bulk_destroy) do |item|
|
|
36
|
+
bulk_destroy_one(model_klass, item)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# This project targets Ruby versions where anonymous block forwarding (`&`) may be unavailable.
|
|
43
|
+
# rubocop:disable Style/ArgumentsForwarding
|
|
44
|
+
def bulk_write_operation(model_klass, operation:, &block)
|
|
45
|
+
apply_bulk_operation(
|
|
46
|
+
model_klass,
|
|
47
|
+
operation:,
|
|
48
|
+
items: bulk_items,
|
|
49
|
+
transaction_mode: bulk_transaction_mode,
|
|
50
|
+
&block
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
# rubocop:enable Style/ArgumentsForwarding
|
|
54
|
+
|
|
55
|
+
def bulk_update_one(model_klass, item)
|
|
56
|
+
id = item.is_a?(Hash) ? item[:id] : nil
|
|
57
|
+
attributes = item.is_a?(Hash) ? item.fetch(:attributes, {}) : {}
|
|
58
|
+
|
|
59
|
+
record_result = find_record(model_klass, id)
|
|
60
|
+
return record_result if record_result.failure?
|
|
61
|
+
|
|
62
|
+
record = record_result.value
|
|
63
|
+
assign_attributes(record, sanitize_attributes(attributes || {}))
|
|
64
|
+
persist_write(record, method_name: :save)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def bulk_destroy_one(model_klass, item)
|
|
68
|
+
id = item.is_a?(Hash) ? item[:id] : item
|
|
69
|
+
|
|
70
|
+
record_result = find_record(model_klass, id)
|
|
71
|
+
return record_result if record_result.failure?
|
|
72
|
+
|
|
73
|
+
persist_write(record_result.value, method_name: :destroy)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|