crspec 0.1.2 → 0.1.3

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.
@@ -4,14 +4,26 @@ require "prism"
4
4
 
5
5
  module Crspec
6
6
  module Transpiler
7
+ # Lint severities:
8
+ # :error - construct is rejected by Crspec at load/run time
9
+ # :warning - construct is concurrency-unsafe and needs manual review
10
+ # :info - construct was auto-rewritten or is worth double-checking
11
+ Lint = Struct.new(:line, :severity, :code, :message)
12
+
7
13
  class Rewriter < Prism::Visitor
8
- attr_reader :transformed_code, :warnings
14
+ attr_reader :transformed_code, :warnings, :lints
9
15
 
10
16
  def initialize(source_code)
11
17
  super()
12
18
  @source_code = source_code
13
19
  @replacements = []
14
- @warnings = []
20
+ @lints = []
21
+ @inside_example_depth = 0
22
+ end
23
+
24
+ # Backwards-compatible string warnings.
25
+ def warnings
26
+ @lints.map { |l| "Line #{l.line}: [#{l.severity}] #{l.message}" }
15
27
  end
16
28
 
17
29
  def transpile
@@ -20,46 +32,188 @@ module Crspec
20
32
  apply_replacements
21
33
  end
22
34
 
23
- def visit_call_node(node)
24
- # Convert RSpec.describe -> Crspec.describe
25
- if node.receiver.is_a?(Prism::ConstantReadNode) &&
26
- node.receiver.name == :RSpec &&
27
- node.name == :describe
28
- @replacements << {
29
- start_offset: node.receiver.location.start_offset,
30
- end_offset: node.receiver.location.end_offset,
31
- text: "Crspec"
32
- }
33
- end
35
+ def safety_score
36
+ return 100 if @lints.empty?
34
37
 
35
- # Flag thread-unsafe before(:all) blocks
36
- if node.name == :before && node.arguments
37
- first_arg = node.arguments.arguments.first
38
- if first_arg.is_a?(Prism::SymbolNode) && first_arg.value == "all"
39
- @warnings << "Line #{node.location.start_line}: [Thread Safety Warning] before(:all) mutates global state across examples."
38
+ deductions = @lints.sum do |l|
39
+ case l.severity
40
+ when :error then 25
41
+ when :warning then 10
42
+ else 2
40
43
  end
41
44
  end
45
+ [100 - deductions, 0].max
46
+ end
42
47
 
43
- super
48
+ def visit_call_node(node)
49
+ rewrite_rspec_receiver(node)
50
+ rewrite_matcher_define(node)
51
+ rewrite_before_all(node)
52
+ rewrite_focus_aliases(node)
53
+ lint_any_instance(node)
54
+ lint_env_mutation(node)
55
+ lint_time_mutation(node)
56
+
57
+ example_call = %i[it specify example].include?(node.name)
58
+ @inside_example_depth += 1 if example_call
59
+ begin
60
+ super
61
+ ensure
62
+ @inside_example_depth -= 1 if example_call
63
+ end
44
64
  end
45
65
 
46
66
  def visit_constant_read_node(node)
47
67
  if node.name == :RSpec
48
- @replacements << {
49
- start_offset: node.location.start_offset,
50
- end_offset: node.location.end_offset,
51
- text: "Crspec"
52
- }
68
+ replace(node.location, "Crspec")
53
69
  end
54
70
  super
55
71
  end
56
72
 
73
+ def visit_class_variable_write_node(node)
74
+ add_lint(node, :warning, :class_variable,
75
+ "class variable mutation (#{node.name}) is shared across all threads/fibers")
76
+ super
77
+ end
78
+
79
+ def visit_class_variable_read_node(node)
80
+ add_lint(node, :warning, :class_variable,
81
+ "class variable read (#{node.name}) implies shared mutable state")
82
+ super
83
+ end
84
+
85
+ def visit_constant_write_node(node)
86
+ if @inside_example_depth.positive?
87
+ add_lint(node, :warning, :constant_mutation,
88
+ "constant assignment (#{node.name}) inside an example mutates global state")
89
+ end
90
+ super
91
+ end
92
+
93
+ def visit_index_operator_write_node(node)
94
+ lint_env_index(node)
95
+ super
96
+ end
97
+
98
+ def visit_index_and_write_node(node)
99
+ lint_env_index(node)
100
+ super
101
+ end
102
+
103
+ def visit_index_or_write_node(node)
104
+ lint_env_index(node)
105
+ super
106
+ end
107
+
57
108
  private
58
109
 
110
+ def rewrite_rspec_receiver(node)
111
+ return unless node.receiver.is_a?(Prism::ConstantReadNode) && node.receiver.name == :RSpec
112
+
113
+ replace(node.receiver.location, "Crspec")
114
+ end
115
+
116
+ # RSpec::Matchers.define -> Crspec::Matchers.define
117
+ def rewrite_matcher_define(node)
118
+ return unless node.name == :define
119
+ return unless node.receiver.is_a?(Prism::ConstantPathNode)
120
+
121
+ path = node.receiver
122
+ parent = path.parent
123
+ return unless parent.is_a?(Prism::ConstantReadNode) && parent.name == :RSpec && path.name == :Matchers
124
+
125
+ replace(parent.location, "Crspec")
126
+ end
127
+
128
+ # before(:all)/before(:context) -> before(:each) with annotation.
129
+ def rewrite_before_all(node)
130
+ return unless %i[before after].include?(node.name) && node.arguments
131
+
132
+ first_arg = node.arguments.arguments.first
133
+ return unless first_arg.is_a?(Prism::SymbolNode) && %w[all context suite].include?(first_arg.value)
134
+
135
+ add_lint(node, :info, :before_all_rewritten,
136
+ "#{node.name}(:#{first_arg.value}) auto-rewritten to #{node.name}(:each); " \
137
+ "review for per-example cost (consider let/let!)")
138
+ replace(first_arg.location, ":each")
139
+ annotate_line(node, "# CRSPEC-MIGRATION: was #{node.name}(:#{first_arg.value}) — now runs per example; consider let/let! for expensive setup")
140
+ end
141
+
142
+ # fit/fdescribe/fcontext are focus aliases; xit etc are already
143
+ # supported natively. Normalize focus to plain forms with a note.
144
+ def rewrite_focus_aliases(node)
145
+ mapping = { fit: "it", fdescribe: "describe", fcontext: "context" }
146
+ replacement = mapping[node.name]
147
+ return unless replacement
148
+ return if node.receiver
149
+
150
+ add_lint(node, :info, :focus_removed,
151
+ "#{node.name} (focus) normalized to #{replacement}")
152
+ replace(node.message_loc, replacement)
153
+ end
154
+
155
+ def lint_any_instance(node)
156
+ if %i[any_instance allow_any_instance_of expect_any_instance_of].include?(node.name)
157
+ add_lint(node, :error, :any_instance,
158
+ "#{node.name} mutates shared class hierarchies at runtime and is rejected by Crspec; " \
159
+ "inject a double or stub the specific instance instead")
160
+ end
161
+ end
162
+
163
+ def lint_env_mutation(node)
164
+ return unless node.name == :[]= && node.receiver.is_a?(Prism::ConstantReadNode) && node.receiver.name == :ENV
165
+
166
+ add_lint(node, :warning, :env_mutation,
167
+ "ENV mutation inside specs is process-global and races across threads")
168
+ end
169
+
170
+ def lint_env_index(node)
171
+ return unless node.receiver.is_a?(Prism::ConstantReadNode) && node.receiver.name == :ENV
172
+
173
+ add_lint(node, :warning, :env_mutation,
174
+ "ENV mutation inside specs is process-global and races across threads")
175
+ end
176
+
177
+ TIME_MUTATORS = %i[travel travel_to travel_back freeze return].freeze
178
+
179
+ def lint_time_mutation(node)
180
+ receiver = node.receiver
181
+ if receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :Timecop
182
+ add_lint(node, :warning, :time_mutation,
183
+ "Timecop.#{node.name} mutates global time state; use ActiveSupport::Testing::TimeHelpers " \
184
+ "scoped per example, or inject a clock")
185
+ end
186
+ end
187
+
188
+ def add_lint(node, severity, code, message)
189
+ @lints << Lint.new(node.location.start_line, severity, code, message)
190
+ end
191
+
192
+ def replace(location, text)
193
+ @replacements << {
194
+ start_offset: location.start_offset,
195
+ end_offset: location.end_offset,
196
+ text: text
197
+ }
198
+ end
199
+
200
+ # Insert a comment line above the node's line.
201
+ def annotate_line(node, comment)
202
+ line_start = @source_code.rindex("\n", node.location.start_offset)
203
+ line_start = line_start.nil? ? 0 : line_start + 1
204
+ indent = @source_code[line_start..node.location.start_offset][/\A[ \t]*/]
205
+ @replacements << {
206
+ start_offset: line_start,
207
+ end_offset: line_start,
208
+ text: "#{indent}#{comment}\n"
209
+ }
210
+ end
211
+
59
212
  def apply_replacements
60
213
  buffer = @source_code.dup
61
214
  # Sort replacements backwards so offsets remain valid during splicing
62
- sorted = @replacements.uniq { |r| [r[:start_offset], r[:end_offset]] }.sort_by { |r| -r[:start_offset] }
215
+ sorted = @replacements.uniq { |r| [r[:start_offset], r[:end_offset], r[:text]] }
216
+ .sort_by { |r| [-r[:start_offset], -r[:end_offset]] }
63
217
  sorted.each do |r|
64
218
  buffer[r[:start_offset]...r[:end_offset]] = r[:text]
65
219
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Crspec
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.3"
5
5
  end
data/lib/crspec.rb CHANGED
@@ -5,13 +5,17 @@ require_relative "crspec/configuration"
5
5
  require_relative "crspec/execution_context"
6
6
  require_relative "crspec/matchers"
7
7
  require_relative "crspec/expectations"
8
+ require_relative "crspec/file_fixtures"
8
9
  require_relative "crspec/example"
10
+ require_relative "crspec/shared_examples"
9
11
  require_relative "crspec/example_group"
10
12
  require_relative "crspec/mock/interceptor"
11
13
  require_relative "crspec/mock/space"
12
14
  require_relative "crspec/mock/double"
13
15
  require_relative "crspec/formatters/progress_formatter"
16
+ require_relative "crspec/status_persistence"
14
17
  require_relative "crspec/runner"
18
+ require_relative "crspec/process_runner"
15
19
  require_relative "crspec/dsl"
16
20
  require_relative "crspec/rails/database_isolation"
17
21
  require_relative "crspec/rails/system_server"
@@ -24,3 +28,5 @@ require_relative "crspec/generators/init"
24
28
  module Crspec
25
29
  class Error < StandardError; end
26
30
  end
31
+
32
+ RSpec = Crspec unless defined?(RSpec)
data/lib/rspec/core.rb ADDED
@@ -0,0 +1,2 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../crspec"
@@ -0,0 +1,2 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../crspec"
@@ -0,0 +1,2 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../crspec"
@@ -0,0 +1,2 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../crspec"
data/lib/rspec.rb ADDED
@@ -0,0 +1,2 @@
1
+ # frozen_string_literal: true
2
+ require_relative "crspec"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: crspec
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aboobacker MK
@@ -10,6 +10,20 @@ bindir: exe
10
10
  cert_chain: []
11
11
  date: 2026-07-31 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: async
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: prism
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -48,20 +62,32 @@ files:
48
62
  - lib/crspec/example_group.rb
49
63
  - lib/crspec/execution_context.rb
50
64
  - lib/crspec/expectations.rb
65
+ - lib/crspec/file_fixtures.rb
51
66
  - lib/crspec/formatters/progress_formatter.rb
52
67
  - lib/crspec/generators/init.rb
53
68
  - lib/crspec/matchers.rb
69
+ - lib/crspec/mock/argument_matchers.rb
54
70
  - lib/crspec/mock/double.rb
55
71
  - lib/crspec/mock/interceptor.rb
56
72
  - lib/crspec/mock/space.rb
73
+ - lib/crspec/process_runner.rb
74
+ - lib/crspec/rails/assets_shim.rb
57
75
  - lib/crspec/rails/database_isolation.rb
58
76
  - lib/crspec/rails/parallel.rb
59
77
  - lib/crspec/rails/request_helpers.rb
60
78
  - lib/crspec/rails/system_server.rb
79
+ - lib/crspec/rails/warden_shim.rb
61
80
  - lib/crspec/runner.rb
81
+ - lib/crspec/shared_examples.rb
82
+ - lib/crspec/status_persistence.rb
62
83
  - lib/crspec/transpiler/cli.rb
63
84
  - lib/crspec/transpiler/rewriter.rb
64
85
  - lib/crspec/version.rb
86
+ - lib/rspec.rb
87
+ - lib/rspec/core.rb
88
+ - lib/rspec/expectations.rb
89
+ - lib/rspec/mocks.rb
90
+ - lib/rspec/rails.rb
65
91
  - sig/crspec.rbs
66
92
  homepage: https://github.com/crspec/crspec
67
93
  licenses: