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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/.tool-versions +1 -0
  3. data/CHANGELOG.md +64 -0
  4. data/LICENSE.txt +21 -0
  5. data/MIGRATION.md +156 -0
  6. data/README.md +249 -0
  7. data/Rakefile +14 -0
  8. data/docs/cookbook.md +605 -0
  9. data/docs/legacy-adoption.md +283 -0
  10. data/docs/quickstart.md +110 -0
  11. data/lib/generators/railsmith/domain/domain_generator.rb +57 -0
  12. data/lib/generators/railsmith/domain/templates/domain.rb.tt +14 -0
  13. data/lib/generators/railsmith/install/install_generator.rb +21 -0
  14. data/lib/generators/railsmith/install/templates/railsmith.rb +10 -0
  15. data/lib/generators/railsmith/model_service/model_service_generator.rb +121 -0
  16. data/lib/generators/railsmith/model_service/templates/model_service.rb.tt +28 -0
  17. data/lib/generators/railsmith/operation/operation_generator.rb +88 -0
  18. data/lib/generators/railsmith/operation/templates/operation.rb.tt +27 -0
  19. data/lib/railsmith/arch_checks/cli.rb +79 -0
  20. data/lib/railsmith/arch_checks/direct_model_access_checker.rb +94 -0
  21. data/lib/railsmith/arch_checks/missing_service_usage_checker.rb +206 -0
  22. data/lib/railsmith/arch_checks/violation.rb +14 -0
  23. data/lib/railsmith/arch_checks.rb +7 -0
  24. data/lib/railsmith/arch_report.rb +96 -0
  25. data/lib/railsmith/base_service/bulk_actions.rb +77 -0
  26. data/lib/railsmith/base_service/bulk_contract.rb +56 -0
  27. data/lib/railsmith/base_service/bulk_execution.rb +68 -0
  28. data/lib/railsmith/base_service/bulk_params.rb +56 -0
  29. data/lib/railsmith/base_service/crud_actions.rb +63 -0
  30. data/lib/railsmith/base_service/crud_error_mapping.rb +78 -0
  31. data/lib/railsmith/base_service/crud_model_resolution.rb +36 -0
  32. data/lib/railsmith/base_service/crud_record_helpers.rb +60 -0
  33. data/lib/railsmith/base_service/crud_transactions.rb +31 -0
  34. data/lib/railsmith/base_service/domain_context_propagation.rb +29 -0
  35. data/lib/railsmith/base_service/dup_helpers.rb +15 -0
  36. data/lib/railsmith/base_service/validation.rb +67 -0
  37. data/lib/railsmith/base_service.rb +96 -0
  38. data/lib/railsmith/configuration.rb +18 -0
  39. data/lib/railsmith/cross_domain_guard.rb +90 -0
  40. data/lib/railsmith/cross_domain_warning_formatter.rb +66 -0
  41. data/lib/railsmith/deep_dup.rb +20 -0
  42. data/lib/railsmith/domain_context.rb +44 -0
  43. data/lib/railsmith/errors.rb +50 -0
  44. data/lib/railsmith/instrumentation.rb +64 -0
  45. data/lib/railsmith/railtie.rb +10 -0
  46. data/lib/railsmith/result.rb +60 -0
  47. data/lib/railsmith/version.rb +5 -0
  48. data/lib/railsmith.rb +31 -0
  49. data/lib/tasks/railsmith.rake +24 -0
  50. data/sig/railsmith.rbs +4 -0
  51. 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