hone 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.
Files changed (105) hide show
  1. checksums.yaml +7 -0
  2. data/.standard.yml +8 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +201 -0
  6. data/Rakefile +10 -0
  7. data/examples/.hone/harness.rb +41 -0
  8. data/examples/README.md +22 -0
  9. data/examples/allocation_patterns.rb +66 -0
  10. data/examples/cpu_patterns.rb +50 -0
  11. data/examples/jit_patterns.rb +69 -0
  12. data/exe/hone +7 -0
  13. data/lib/hone/adapters/base.rb +35 -0
  14. data/lib/hone/adapters/fasterer.rb +38 -0
  15. data/lib/hone/adapters/rubocop_performance.rb +85 -0
  16. data/lib/hone/analyzer.rb +258 -0
  17. data/lib/hone/cli.rb +247 -0
  18. data/lib/hone/config.rb +93 -0
  19. data/lib/hone/correlator.rb +250 -0
  20. data/lib/hone/exit_codes.rb +10 -0
  21. data/lib/hone/finding.rb +64 -0
  22. data/lib/hone/finding_filter.rb +57 -0
  23. data/lib/hone/formatters/base.rb +25 -0
  24. data/lib/hone/formatters/filterable.rb +31 -0
  25. data/lib/hone/formatters/github.rb +71 -0
  26. data/lib/hone/formatters/json.rb +75 -0
  27. data/lib/hone/formatters/junit.rb +154 -0
  28. data/lib/hone/formatters/sarif.rb +179 -0
  29. data/lib/hone/formatters/tsv.rb +49 -0
  30. data/lib/hone/harness.rb +57 -0
  31. data/lib/hone/harness_generator.rb +128 -0
  32. data/lib/hone/harness_runner.rb +172 -0
  33. data/lib/hone/method_map.rb +140 -0
  34. data/lib/hone/patterns/README.md +174 -0
  35. data/lib/hone/patterns/array_compact.rb +105 -0
  36. data/lib/hone/patterns/array_include_set.rb +34 -0
  37. data/lib/hone/patterns/base.rb +90 -0
  38. data/lib/hone/patterns/block_to_proc.rb +109 -0
  39. data/lib/hone/patterns/bsearch_vs_find.rb +80 -0
  40. data/lib/hone/patterns/chars_map_ord.rb +42 -0
  41. data/lib/hone/patterns/chars_to_variable.rb +136 -0
  42. data/lib/hone/patterns/chars_to_variable_tainted.rb +136 -0
  43. data/lib/hone/patterns/constant_regexp.rb +74 -0
  44. data/lib/hone/patterns/count_vs_size.rb +35 -0
  45. data/lib/hone/patterns/divmod.rb +92 -0
  46. data/lib/hone/patterns/dynamic_ivar.rb +44 -0
  47. data/lib/hone/patterns/dynamic_ivar_get.rb +33 -0
  48. data/lib/hone/patterns/each_with_index.rb +116 -0
  49. data/lib/hone/patterns/each_with_object.rb +63 -0
  50. data/lib/hone/patterns/flatten_once.rb +28 -0
  51. data/lib/hone/patterns/gsub_to_tr.rb +48 -0
  52. data/lib/hone/patterns/hash_each_key.rb +41 -0
  53. data/lib/hone/patterns/hash_each_value.rb +31 -0
  54. data/lib/hone/patterns/hash_keys_include.rb +30 -0
  55. data/lib/hone/patterns/hash_merge_bang.rb +33 -0
  56. data/lib/hone/patterns/hash_values_include.rb +31 -0
  57. data/lib/hone/patterns/inject_sum.rb +48 -0
  58. data/lib/hone/patterns/kernel_loop.rb +27 -0
  59. data/lib/hone/patterns/lazy_ivar.rb +39 -0
  60. data/lib/hone/patterns/map_compact.rb +32 -0
  61. data/lib/hone/patterns/map_flatten.rb +31 -0
  62. data/lib/hone/patterns/map_select_chain.rb +32 -0
  63. data/lib/hone/patterns/parallel_assignment.rb +127 -0
  64. data/lib/hone/patterns/positive_predicate.rb +27 -0
  65. data/lib/hone/patterns/range_include.rb +34 -0
  66. data/lib/hone/patterns/redundant_string_chars.rb +82 -0
  67. data/lib/hone/patterns/regexp_match.rb +126 -0
  68. data/lib/hone/patterns/reverse_each.rb +30 -0
  69. data/lib/hone/patterns/reverse_first.rb +40 -0
  70. data/lib/hone/patterns/select_count.rb +32 -0
  71. data/lib/hone/patterns/select_first.rb +31 -0
  72. data/lib/hone/patterns/select_map.rb +32 -0
  73. data/lib/hone/patterns/shuffle_first.rb +30 -0
  74. data/lib/hone/patterns/slice_with_length.rb +48 -0
  75. data/lib/hone/patterns/sort_by_first.rb +31 -0
  76. data/lib/hone/patterns/sort_by_last.rb +31 -0
  77. data/lib/hone/patterns/sort_first.rb +52 -0
  78. data/lib/hone/patterns/sort_last.rb +30 -0
  79. data/lib/hone/patterns/sort_reverse.rb +53 -0
  80. data/lib/hone/patterns/string_casecmp.rb +54 -0
  81. data/lib/hone/patterns/string_chars_each.rb +56 -0
  82. data/lib/hone/patterns/string_concat_in_loop.rb +116 -0
  83. data/lib/hone/patterns/string_delete_prefix.rb +53 -0
  84. data/lib/hone/patterns/string_delete_suffix.rb +53 -0
  85. data/lib/hone/patterns/string_empty.rb +64 -0
  86. data/lib/hone/patterns/string_end_with.rb +81 -0
  87. data/lib/hone/patterns/string_shovel.rb +75 -0
  88. data/lib/hone/patterns/string_start_with.rb +80 -0
  89. data/lib/hone/patterns/taint_tracking_base.rb +230 -0
  90. data/lib/hone/patterns/times_map.rb +38 -0
  91. data/lib/hone/patterns/uniq_by.rb +32 -0
  92. data/lib/hone/patterns/yield_vs_block.rb +72 -0
  93. data/lib/hone/profilers/base.rb +162 -0
  94. data/lib/hone/profilers/factory.rb +31 -0
  95. data/lib/hone/profilers/memory_profiler.rb +213 -0
  96. data/lib/hone/profilers/stackprof.rb +99 -0
  97. data/lib/hone/profilers/vernier.rb +147 -0
  98. data/lib/hone/reporter.rb +371 -0
  99. data/lib/hone/scanner.rb +75 -0
  100. data/lib/hone/suggestion_generator.rb +23 -0
  101. data/lib/hone/version.rb +5 -0
  102. data/lib/hone.rb +108 -0
  103. data/logo.png +0 -0
  104. data/sig/hone.rbs +4 -0
  105. metadata +176 -0
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ module Formatters
5
+ class JUnit < Base
6
+ PRIORITY_TO_TYPE = {
7
+ hot_cpu: "failure",
8
+ hot_alloc: "failure",
9
+ jit_unfriendly: "failure",
10
+ warm: "failure",
11
+ cold: "skipped",
12
+ unknown: "failure"
13
+ }.freeze
14
+
15
+ def format
16
+ require_rexml!
17
+ doc = build_document
18
+ output = +""
19
+ formatter = REXML::Formatters::Pretty.new(2)
20
+ formatter.compact = true
21
+ output << %(<?xml version="1.0" encoding="UTF-8"?>\n)
22
+ formatter.write(doc.root, output)
23
+ output
24
+ end
25
+
26
+ private
27
+
28
+ def require_rexml!
29
+ require "rexml/document"
30
+ rescue LoadError
31
+ raise Hone::Error, "JUnit format requires the 'rexml' gem. Add it to your Gemfile: gem 'rexml'"
32
+ end
33
+
34
+ def build_document
35
+ doc = REXML::Document.new
36
+ testsuites = doc.add_element("testsuites")
37
+ testsuites.add_attribute("name", "Hone")
38
+
39
+ findings_by_file = filtered_findings.group_by(&:file)
40
+
41
+ total_tests = 0
42
+ total_failures = 0
43
+ total_skipped = 0
44
+
45
+ findings_by_file.each do |file, file_findings|
46
+ suite = testsuites.add_element("testsuite")
47
+ suite.add_attribute("name", file)
48
+
49
+ suite_failures = 0
50
+ suite_skipped = 0
51
+
52
+ file_findings.each do |finding|
53
+ add_testcase(suite, finding, file)
54
+
55
+ case result_type(finding)
56
+ when "failure"
57
+ suite_failures += 1
58
+ when "skipped"
59
+ suite_skipped += 1
60
+ end
61
+ end
62
+
63
+ suite.add_attribute("tests", file_findings.size.to_s)
64
+ suite.add_attribute("failures", suite_failures.to_s)
65
+ suite.add_attribute("skipped", suite_skipped.to_s)
66
+
67
+ total_tests += file_findings.size
68
+ total_failures += suite_failures
69
+ total_skipped += suite_skipped
70
+ end
71
+
72
+ testsuites.add_attribute("tests", total_tests.to_s)
73
+ testsuites.add_attribute("failures", total_failures.to_s)
74
+ testsuites.add_attribute("skipped", total_skipped.to_s)
75
+
76
+ doc
77
+ end
78
+
79
+ def add_testcase(suite, finding, file)
80
+ testcase = suite.add_element("testcase")
81
+ testcase.add_attribute("name", testcase_name(finding))
82
+ testcase.add_attribute("classname", classname_from_file(file))
83
+
84
+ case result_type(finding)
85
+ when "failure"
86
+ add_failure_element(testcase, finding)
87
+ when "skipped"
88
+ add_skipped_element(testcase, finding)
89
+ end
90
+ end
91
+
92
+ def testcase_name(finding)
93
+ name = finding.pattern_id.to_s
94
+ name += " at line #{finding.line}" if finding.line
95
+ name
96
+ end
97
+
98
+ def classname_from_file(file)
99
+ # Convert path like "app/models/order.rb" to "app.models.order"
100
+ file
101
+ .sub(/\.rb\z/, "")
102
+ .tr("/", ".")
103
+ end
104
+
105
+ def result_type(finding)
106
+ priority = finding.priority || :unknown
107
+ PRIORITY_TO_TYPE.fetch(priority, "failure")
108
+ end
109
+
110
+ def add_failure_element(testcase, finding)
111
+ failure = testcase.add_element("failure")
112
+ failure.add_attribute("message", finding.message)
113
+ failure.add_attribute("type", (finding.priority || :unknown).to_s)
114
+ failure.add_text(failure_details(finding))
115
+ end
116
+
117
+ def add_skipped_element(testcase, finding)
118
+ skipped = testcase.add_element("skipped")
119
+ skipped.add_attribute("message", finding.message)
120
+ end
121
+
122
+ def failure_details(finding)
123
+ heat = priority_label(finding.priority || :unknown)
124
+
125
+ cpu_info = finding.cpu_percent ? "#{format_percent(finding.cpu_percent)} CPU" : nil
126
+ alloc_info = finding.alloc_percent ? "#{format_percent(finding.alloc_percent)} alloc" : nil
127
+ metrics = [cpu_info, alloc_info].compact.join(", ")
128
+
129
+ details = if metrics.empty?
130
+ "[#{heat}] #{finding.message}"
131
+ else
132
+ "[#{heat} #{metrics}] #{finding.message}"
133
+ end
134
+
135
+ details += "\nCode: #{finding.code}" if finding.code
136
+ details += "\nFile: #{finding.file}:#{finding.line}" if finding.file && finding.line
137
+ details
138
+ end
139
+
140
+ def priority_label(priority)
141
+ case priority
142
+ when :hot_cpu then "HOT-CPU"
143
+ when :hot_alloc then "HOT-ALLOC"
144
+ when :jit_unfriendly then "JIT-UNFRIENDLY"
145
+ else priority.to_s.upcase
146
+ end
147
+ end
148
+
149
+ def format_percent(percent)
150
+ "%.1f%%" % percent
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Hone
6
+ module Formatters
7
+ # SARIF (Static Analysis Results Interchange Format) formatter for GitHub Code Scanning.
8
+ #
9
+ # SARIF is a standard format for static analysis tools, supported by GitHub Code Scanning
10
+ # and other security/code quality platforms.
11
+ #
12
+ # @see https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
13
+ #
14
+ # @example Usage
15
+ # formatter = Hone::Formatters::SARIF.new(findings)
16
+ # puts formatter.format
17
+ class SARIF < Base
18
+ SARIF_VERSION = "2.1.0"
19
+ SARIF_SCHEMA = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
20
+
21
+ # Maps Hone priority levels to SARIF severity levels.
22
+ # - error: A serious problem that should be addressed immediately
23
+ # - warning: A potential problem that should be reviewed
24
+ # - note: Informational finding
25
+ PRIORITY_TO_LEVEL = {
26
+ hot_cpu: "error",
27
+ hot_alloc: "error",
28
+ jit_unfriendly: "warning",
29
+ warm: "warning",
30
+ cold: "note",
31
+ unknown: "note"
32
+ }.freeze
33
+
34
+ def format
35
+ ::JSON.pretty_generate(build_sarif)
36
+ end
37
+
38
+ private
39
+
40
+ def build_sarif
41
+ {
42
+ "$schema" => SARIF_SCHEMA,
43
+ "version" => SARIF_VERSION,
44
+ "runs" => [build_run]
45
+ }
46
+ end
47
+
48
+ def build_run
49
+ {
50
+ "tool" => build_tool,
51
+ "results" => build_results
52
+ }
53
+ end
54
+
55
+ def build_tool
56
+ {
57
+ "driver" => {
58
+ "name" => "Hone",
59
+ "version" => Hone::VERSION,
60
+ "informationUri" => "https://github.com/your-org/hone",
61
+ "rules" => build_rules
62
+ }
63
+ }
64
+ end
65
+
66
+ def build_rules
67
+ # Collect unique pattern IDs from findings to build rule definitions
68
+ unique_patterns = @findings.map(&:pattern_id).uniq
69
+
70
+ unique_patterns.map { |pattern_id| build_rule(pattern_id) }
71
+ end
72
+
73
+ def build_rule(pattern_id)
74
+ {
75
+ "id" => pattern_id.to_s,
76
+ "name" => humanize_pattern_id(pattern_id),
77
+ "shortDescription" => {
78
+ "text" => "#{humanize_pattern_id(pattern_id)} optimization opportunity"
79
+ },
80
+ "helpUri" => "https://github.com/your-org/hone##{pattern_id}"
81
+ }
82
+ end
83
+
84
+ def humanize_pattern_id(pattern_id)
85
+ pattern_id.to_s.split("_").map(&:capitalize).join(" ")
86
+ end
87
+
88
+ def build_results
89
+ @findings.map { |finding| build_result(finding) }
90
+ end
91
+
92
+ def build_result(finding)
93
+ result = {
94
+ "ruleId" => finding.pattern_id.to_s,
95
+ "level" => sarif_level(finding),
96
+ "message" => {
97
+ "text" => build_message_text(finding)
98
+ },
99
+ "locations" => [build_location(finding)]
100
+ }
101
+
102
+ # Add optional properties if available
103
+ result["partialFingerprints"] = build_fingerprints(finding) if finding.method_name
104
+
105
+ result
106
+ end
107
+
108
+ def sarif_level(finding)
109
+ priority = finding.priority || :unknown
110
+ PRIORITY_TO_LEVEL.fetch(priority, "note")
111
+ end
112
+
113
+ def build_message_text(finding)
114
+ parts = [finding.message]
115
+
116
+ if finding.cpu_percent
117
+ parts << "CPU: %.1f%%" % finding.cpu_percent
118
+ end
119
+
120
+ if finding.alloc_percent
121
+ parts << "Allocations: %.1f%%" % finding.alloc_percent
122
+ end
123
+
124
+ if finding.speedup
125
+ parts << "Expected speedup: #{finding.speedup}"
126
+ end
127
+
128
+ parts.join(" | ")
129
+ end
130
+
131
+ def build_location(finding)
132
+ location = {
133
+ "physicalLocation" => {
134
+ "artifactLocation" => {
135
+ "uri" => finding.file
136
+ },
137
+ "region" => build_region(finding)
138
+ }
139
+ }
140
+
141
+ # Add logical location (method name) if available
142
+ if finding.method_name
143
+ location["logicalLocations"] = [
144
+ {
145
+ "name" => finding.method_name,
146
+ "kind" => "function"
147
+ }
148
+ ]
149
+ end
150
+
151
+ location
152
+ end
153
+
154
+ def build_region(finding)
155
+ region = {
156
+ "startLine" => finding.line
157
+ }
158
+
159
+ region["startColumn"] = finding.column if finding.column
160
+
161
+ # Include the source code snippet if available
162
+ if finding.code
163
+ region["snippet"] = {
164
+ "text" => finding.code
165
+ }
166
+ end
167
+
168
+ region
169
+ end
170
+
171
+ def build_fingerprints(finding)
172
+ # Partial fingerprints help GitHub track results across runs
173
+ {
174
+ "methodName" => finding.method_name
175
+ }
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ module Formatters
5
+ class TSV < Base
6
+ HEADERS = %w[priority cpu_percent alloc_percent file line pattern_id message method_name].freeze
7
+ COLUMN_SEPARATOR = "\t"
8
+
9
+ def format
10
+ lines = [header_row]
11
+ lines.concat(filtered_findings.map { |finding| format_row(finding) })
12
+ lines.join("\n")
13
+ end
14
+
15
+ private
16
+
17
+ def header_row
18
+ HEADERS.join(COLUMN_SEPARATOR)
19
+ end
20
+
21
+ def format_row(finding)
22
+ [
23
+ (finding.priority || "").to_s,
24
+ format_percent(finding.cpu_percent),
25
+ format_percent(finding.alloc_percent),
26
+ finding.file.to_s,
27
+ finding.line.to_s,
28
+ finding.pattern_id.to_s,
29
+ escape_field(finding.message.to_s),
30
+ finding.method_name.to_s
31
+ ].join(COLUMN_SEPARATOR)
32
+ end
33
+
34
+ def format_percent(value)
35
+ return "" if value.nil?
36
+
37
+ value.to_s
38
+ end
39
+
40
+ def escape_field(value)
41
+ # Escape tabs and newlines for TSV compatibility
42
+ value
43
+ .gsub("\t", "\\t")
44
+ .gsub("\n", "\\n")
45
+ .gsub("\r", "\\r")
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ class Harness
5
+ attr_reader :setup_block, :exercise_block, :teardown_block, :iterations
6
+
7
+ def self.load(path)
8
+ raise Hone::Error, "Harness file not found: #{path}" unless File.exist?(path)
9
+
10
+ harness = new
11
+ harness.instance_eval(File.read(path), path)
12
+ harness
13
+ end
14
+
15
+ def initialize
16
+ @setup_block = nil
17
+ @exercise_block = nil
18
+ @teardown_block = nil
19
+ @iterations = 1
20
+ end
21
+
22
+ # Setup block - runs once before profiling (not profiled)
23
+ # Used for loading the application, creating test data, etc.
24
+ def setup(&block)
25
+ @setup_block = block
26
+ end
27
+
28
+ # Exercise block - the code to profile
29
+ # @param iterations [Integer] Number of times to run the block during profiling
30
+ def exercise(iterations: 1, &block)
31
+ @iterations = iterations
32
+ @exercise_block = block
33
+ end
34
+
35
+ # Teardown block - runs once after profiling (not profiled)
36
+ # Used for cleanup, closing connections, etc.
37
+ def teardown(&block)
38
+ @teardown_block = block
39
+ end
40
+
41
+ def run_setup
42
+ @setup_block&.call
43
+ end
44
+
45
+ def run_exercise
46
+ @exercise_block&.call
47
+ end
48
+
49
+ def run_teardown
50
+ @teardown_block&.call
51
+ end
52
+
53
+ def valid?
54
+ !@exercise_block.nil?
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Hone
6
+ class HarnessGenerator
7
+ HARNESS_DIR = ".hone"
8
+ HARNESS_FILE = "harness.rb"
9
+
10
+ def initialize(rails: false)
11
+ @rails = rails
12
+ end
13
+
14
+ def generate
15
+ FileUtils.mkdir_p(HARNESS_DIR)
16
+
17
+ path = File.join(HARNESS_DIR, HARNESS_FILE)
18
+
19
+ if File.exist?(path)
20
+ puts "Harness already exists: #{path}"
21
+ puts "Delete it first if you want to regenerate."
22
+ return false
23
+ end
24
+
25
+ template = @rails ? rails_template : ruby_template
26
+ File.write(path, template)
27
+
28
+ puts "Created #{path}"
29
+ puts
30
+ puts "Next steps:"
31
+ puts " 1. Edit #{path} to exercise your application's hot paths"
32
+ puts " 2. Run: hone profile"
33
+ puts " 3. Run: hone analyze ."
34
+ puts
35
+
36
+ true
37
+ end
38
+
39
+ private
40
+
41
+ def ruby_template
42
+ <<~RUBY
43
+ # frozen_string_literal: true
44
+
45
+ # Hone Performance Harness
46
+ # ========================
47
+ # This file defines how to exercise your code for profiling.
48
+ #
49
+ # Run with: hone profile
50
+ # Analyze: hone analyze . (uses generated profiles automatically)
51
+
52
+ # Setup: Load your application (not profiled)
53
+ setup do
54
+ # Load your library or application
55
+ # require_relative "../lib/my_gem"
56
+
57
+ # Create any test data needed
58
+ # @data = generate_test_data
59
+ end
60
+
61
+ # Exercise: The code to profile
62
+ # This block runs multiple times during profiling.
63
+ # Put your realistic workload here.
64
+ exercise iterations: 100 do
65
+ # Example: Call your hot methods
66
+ # result = MyClass.new(@data).process
67
+ #
68
+ # Example: Simulate typical usage
69
+ # parser = Parser.new
70
+ # 100.times { parser.parse(sample_input) }
71
+ end
72
+
73
+ # Teardown: Cleanup (not profiled)
74
+ teardown do
75
+ # Close connections, clean up temp files, etc.
76
+ end
77
+ RUBY
78
+ end
79
+
80
+ def rails_template
81
+ <<~RUBY
82
+ # frozen_string_literal: true
83
+
84
+ # Hone Performance Harness (Rails)
85
+ # =================================
86
+ # This file defines how to exercise your Rails app for profiling.
87
+ #
88
+ # Run with: hone profile
89
+ # Analyze: hone analyze app/ (uses generated profiles automatically)
90
+
91
+ # Setup: Boot Rails (not profiled)
92
+ setup do
93
+ require_relative "../config/environment"
94
+ Rails.application.eager_load!
95
+
96
+ # Create test data if needed
97
+ # @user = User.first || User.create!(name: "Test", email: "test@example.com")
98
+ # @items = Item.limit(10).to_a
99
+ end
100
+
101
+ # Exercise: Your hot paths
102
+ # This block runs multiple times during profiling.
103
+ # Replace with YOUR app's actual hot code paths.
104
+ exercise iterations: 100 do
105
+ # Example: Model queries
106
+ # User.where(active: true).includes(:orders).limit(10).to_a
107
+
108
+ # Example: Business logic
109
+ # Order.new(user: @user, items: @items).calculate_total
110
+
111
+ # Example: Service objects
112
+ # PaymentProcessor.new(order).validate
113
+
114
+ # Example: Simulate a controller action
115
+ # app = Rails.application
116
+ # env = Rack::MockRequest.env_for("/users/1")
117
+ # app.call(env)
118
+ end
119
+
120
+ # Teardown: Cleanup (not profiled)
121
+ teardown do
122
+ # Clean up test data if needed
123
+ # @user&.destroy
124
+ end
125
+ RUBY
126
+ end
127
+ end
128
+ end