shakapacker 8.4.0 → 9.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 (166) hide show
  1. checksums.yaml +4 -4
  2. data/.eslintignore +1 -0
  3. data/.eslintrc.fast.js +40 -0
  4. data/.eslintrc.js +48 -0
  5. data/.github/STATUS.md +1 -0
  6. data/.github/workflows/claude-code-review.yml +54 -0
  7. data/.github/workflows/claude.yml +50 -0
  8. data/.github/workflows/dummy.yml +8 -4
  9. data/.github/workflows/generator.yml +17 -14
  10. data/.github/workflows/node.yml +23 -1
  11. data/.github/workflows/ruby.yml +11 -0
  12. data/.github/workflows/test-bundlers.yml +170 -0
  13. data/.gitignore +17 -0
  14. data/.husky/pre-commit +2 -0
  15. data/.npmignore +56 -0
  16. data/.prettierignore +3 -0
  17. data/.rubocop.yml +1 -0
  18. data/.yalcignore +26 -0
  19. data/CHANGELOG.md +156 -18
  20. data/CLAUDE.md +29 -0
  21. data/CONTRIBUTING.md +138 -20
  22. data/Gemfile.lock +3 -3
  23. data/README.md +130 -5
  24. data/Rakefile +39 -4
  25. data/TODO.md +50 -0
  26. data/TODO_v9.md +87 -0
  27. data/conductor-setup.sh +70 -0
  28. data/conductor.json +7 -0
  29. data/docs/cdn_setup.md +379 -0
  30. data/docs/css-modules-export-mode.md +512 -0
  31. data/docs/deployment.md +10 -1
  32. data/docs/optional-peer-dependencies.md +198 -0
  33. data/docs/peer-dependencies.md +60 -0
  34. data/docs/rspack.md +190 -0
  35. data/docs/rspack_migration_guide.md +202 -0
  36. data/docs/transpiler-migration.md +188 -0
  37. data/docs/transpiler-performance.md +179 -0
  38. data/docs/troubleshooting.md +5 -0
  39. data/docs/typescript-migration.md +378 -0
  40. data/docs/typescript.md +99 -0
  41. data/docs/using_esbuild_loader.md +3 -3
  42. data/docs/using_swc_loader.md +5 -3
  43. data/docs/v6_upgrade.md +10 -0
  44. data/docs/v9_upgrade.md +413 -0
  45. data/lib/install/bin/shakapacker +3 -5
  46. data/lib/install/config/rspack/rspack.config.js +6 -0
  47. data/lib/install/config/rspack/rspack.config.ts +7 -0
  48. data/lib/install/config/shakapacker.yml +12 -2
  49. data/lib/install/config/webpack/webpack.config.ts +7 -0
  50. data/lib/install/package.json +38 -0
  51. data/lib/install/template.rb +194 -44
  52. data/lib/shakapacker/configuration.rb +141 -0
  53. data/lib/shakapacker/dev_server_runner.rb +25 -5
  54. data/lib/shakapacker/doctor.rb +844 -0
  55. data/lib/shakapacker/manifest.rb +4 -2
  56. data/lib/shakapacker/rspack_runner.rb +19 -0
  57. data/lib/shakapacker/runner.rb +144 -4
  58. data/lib/shakapacker/swc_migrator.rb +376 -0
  59. data/lib/shakapacker/utils/manager.rb +2 -0
  60. data/lib/shakapacker/version.rb +1 -1
  61. data/lib/shakapacker/version_checker.rb +1 -1
  62. data/lib/shakapacker/webpack_runner.rb +4 -42
  63. data/lib/shakapacker.rb +2 -1
  64. data/lib/tasks/shakapacker/doctor.rake +8 -0
  65. data/lib/tasks/shakapacker/install.rake +12 -2
  66. data/lib/tasks/shakapacker/migrate_to_swc.rake +13 -0
  67. data/lib/tasks/shakapacker.rake +1 -0
  68. data/package/.npmignore +4 -0
  69. data/package/babel/preset.ts +56 -0
  70. data/package/config.ts +175 -0
  71. data/package/{dev_server.js → dev_server.ts} +8 -5
  72. data/package/env.ts +92 -0
  73. data/package/environments/base.ts +138 -0
  74. data/package/environments/development.ts +90 -0
  75. data/package/environments/production.ts +80 -0
  76. data/package/environments/test.ts +53 -0
  77. data/package/environments/types.ts +90 -0
  78. data/package/esbuild/index.ts +42 -0
  79. data/package/index.d.ts +3 -97
  80. data/package/index.ts +52 -0
  81. data/package/loaders.d.ts +28 -0
  82. data/package/optimization/rspack.ts +36 -0
  83. data/package/optimization/webpack.ts +57 -0
  84. data/package/plugins/rspack.ts +103 -0
  85. data/package/plugins/webpack.ts +62 -0
  86. data/package/rspack/index.ts +64 -0
  87. data/package/rules/{babel.js → babel.ts} +2 -2
  88. data/package/rules/{coffee.js → coffee.ts} +1 -1
  89. data/package/rules/css.ts +3 -0
  90. data/package/rules/{erb.js → erb.ts} +1 -1
  91. data/package/rules/esbuild.ts +10 -0
  92. data/package/rules/file.ts +40 -0
  93. data/package/rules/{jscommon.js → jscommon.ts} +4 -4
  94. data/package/rules/{less.js → less.ts} +4 -4
  95. data/package/rules/raw.ts +25 -0
  96. data/package/rules/rspack.ts +176 -0
  97. data/package/rules/{sass.js → sass.ts} +7 -3
  98. data/package/rules/{stylus.js → stylus.ts} +4 -8
  99. data/package/rules/swc.ts +10 -0
  100. data/package/rules/{index.js → webpack.ts} +1 -1
  101. data/package/swc/index.ts +54 -0
  102. data/package/types/README.md +87 -0
  103. data/package/types/index.ts +60 -0
  104. data/package/types.ts +108 -0
  105. data/package/utils/configPath.ts +6 -0
  106. data/package/utils/debug.ts +49 -0
  107. data/package/utils/defaultConfigPath.ts +4 -0
  108. data/package/utils/errorCodes.ts +219 -0
  109. data/package/utils/errorHelpers.ts +143 -0
  110. data/package/utils/getStyleRule.ts +64 -0
  111. data/package/utils/helpers.ts +85 -0
  112. data/package/utils/{inliningCss.js → inliningCss.ts} +3 -3
  113. data/package/utils/pathValidation.ts +139 -0
  114. data/package/utils/requireOrError.ts +15 -0
  115. data/package/utils/snakeToCamelCase.ts +5 -0
  116. data/package/utils/typeGuards.ts +342 -0
  117. data/package/utils/validateDependencies.ts +61 -0
  118. data/package/webpack-types.d.ts +33 -0
  119. data/package/webpackDevServerConfig.ts +117 -0
  120. data/package.json +134 -9
  121. data/scripts/remove-use-strict.js +45 -0
  122. data/scripts/type-check-no-emit.js +27 -0
  123. data/test/package/config.test.js +3 -0
  124. data/test/package/env.test.js +42 -7
  125. data/test/package/environments/base.test.js +5 -1
  126. data/test/package/rules/babel.test.js +16 -0
  127. data/test/package/rules/esbuild.test.js +1 -1
  128. data/test/package/rules/raw.test.js +40 -7
  129. data/test/package/rules/swc.test.js +1 -1
  130. data/test/package/rules/webpack.test.js +35 -0
  131. data/test/package/staging.test.js +4 -3
  132. data/test/package/transpiler-defaults.test.js +127 -0
  133. data/test/peer-dependencies.sh +85 -0
  134. data/test/scripts/remove-use-strict.test.js +125 -0
  135. data/test/typescript/build.test.js +118 -0
  136. data/test/typescript/environments.test.js +107 -0
  137. data/test/typescript/pathValidation.test.js +142 -0
  138. data/test/typescript/securityValidation.test.js +182 -0
  139. data/tools/README.md +124 -0
  140. data/tools/css-modules-v9-codemod.js +179 -0
  141. data/tsconfig.eslint.json +16 -0
  142. data/tsconfig.json +38 -0
  143. data/yarn.lock +2704 -767
  144. metadata +111 -41
  145. data/package/babel/preset.js +0 -48
  146. data/package/config.js +0 -56
  147. data/package/env.js +0 -48
  148. data/package/environments/base.js +0 -171
  149. data/package/environments/development.js +0 -13
  150. data/package/environments/production.js +0 -88
  151. data/package/environments/test.js +0 -3
  152. data/package/esbuild/index.js +0 -40
  153. data/package/index.js +0 -40
  154. data/package/rules/css.js +0 -3
  155. data/package/rules/esbuild.js +0 -10
  156. data/package/rules/file.js +0 -29
  157. data/package/rules/raw.js +0 -5
  158. data/package/rules/swc.js +0 -10
  159. data/package/swc/index.js +0 -50
  160. data/package/utils/configPath.js +0 -4
  161. data/package/utils/defaultConfigPath.js +0 -2
  162. data/package/utils/getStyleRule.js +0 -40
  163. data/package/utils/helpers.js +0 -62
  164. data/package/utils/snakeToCamelCase.js +0 -5
  165. data/package/webpackDevServerConfig.js +0 -71
  166. data/test/package/rules/index.test.js +0 -16
@@ -0,0 +1,844 @@
1
+ require "json"
2
+ require "pathname"
3
+ require "open3"
4
+ require "semantic_range"
5
+
6
+ module Shakapacker
7
+ class Doctor
8
+ attr_reader :config, :root_path, :issues, :warnings, :info
9
+
10
+ def initialize(config = nil, root_path = nil)
11
+ @config = config || Shakapacker.config
12
+ @root_path = root_path || (defined?(Rails) ? Rails.root : Pathname.new(Dir.pwd))
13
+ @issues = []
14
+ @warnings = []
15
+ @info = []
16
+ end
17
+
18
+ def run
19
+ perform_checks
20
+ report_results
21
+ end
22
+
23
+ def success?
24
+ @issues.empty?
25
+ end
26
+
27
+ private
28
+
29
+ def perform_checks
30
+ # Core configuration checks
31
+ check_config_file
32
+ check_entry_points if config_exists?
33
+ check_output_paths if config_exists?
34
+ check_deprecated_config if config_exists?
35
+
36
+ # Environment checks
37
+ check_node_installation
38
+ check_package_manager
39
+ check_binstub
40
+ check_version_consistency
41
+ check_environment_consistency
42
+
43
+ # Dependency checks
44
+ check_javascript_transpiler_dependencies if config_exists?
45
+ check_css_dependencies
46
+ check_css_modules_configuration
47
+ check_bundler_dependencies if config_exists?
48
+ check_file_type_dependencies if config_exists?
49
+ check_sri_dependencies if config_exists?
50
+ check_peer_dependencies
51
+
52
+ # Platform and migration checks
53
+ check_windows_platform
54
+ check_legacy_webpacker_files
55
+
56
+ # Build and compilation checks
57
+ check_assets_compilation if config_exists?
58
+ end
59
+
60
+ def check_config_file
61
+ unless config.config_path.exist?
62
+ @issues << "Configuration file not found at #{config.config_path}"
63
+ end
64
+ end
65
+
66
+ def check_entry_points
67
+ # Check for invalid configuration first
68
+ if config.data[:source_entry_path] == "/" && config.nested_entries?
69
+ @issues << "Invalid configuration: cannot use '/' as source_entry_path with nested_entries: true"
70
+ return # Don't try to check files when config is invalid
71
+ end
72
+
73
+ source_entry_path = config.source_path.join(config.data[:source_entry_path] || "packs")
74
+
75
+ unless source_entry_path.exist?
76
+ @issues << "Source entry path #{source_entry_path} does not exist"
77
+ return
78
+ end
79
+
80
+ # Check for at least one entry point
81
+ entry_files = Dir.glob(File.join(source_entry_path, "**/*.{js,jsx,ts,tsx,coffee}"))
82
+ if entry_files.empty?
83
+ @warnings << "No entry point files found in #{source_entry_path}"
84
+ end
85
+ end
86
+
87
+ def check_output_paths
88
+ public_output_path = config.public_output_path
89
+
90
+ # Check if output directory is writable
91
+ if public_output_path.exist?
92
+ unless File.writable?(public_output_path)
93
+ @issues << "Public output path #{public_output_path} is not writable"
94
+ end
95
+ elsif public_output_path.parent.exist?
96
+ unless File.writable?(public_output_path.parent)
97
+ @issues << "Cannot create public output path #{public_output_path} (parent directory not writable)"
98
+ end
99
+ end
100
+
101
+ # Check manifest.json
102
+ manifest_path = config.manifest_path
103
+ if manifest_path.exist?
104
+ unless File.readable?(manifest_path)
105
+ @issues << "Manifest file #{manifest_path} exists but is not readable"
106
+ end
107
+
108
+ # Check if manifest is stale
109
+ begin
110
+ manifest_content = JSON.parse(File.read(manifest_path))
111
+ if manifest_content.empty?
112
+ @warnings << "Manifest file is empty - you may need to run 'rails assets:precompile'"
113
+ end
114
+ rescue JSON::ParserError
115
+ @issues << "Manifest file #{manifest_path} contains invalid JSON"
116
+ end
117
+ end
118
+
119
+ # Check cache path
120
+ cache_path = config.cache_path
121
+ if cache_path.exist? && !File.writable?(cache_path)
122
+ @issues << "Cache path #{cache_path} is not writable"
123
+ end
124
+ end
125
+
126
+ def check_deprecated_config
127
+ config_file = File.read(config.config_path)
128
+
129
+ if config_file.include?("webpack_loader:")
130
+ @warnings << "Deprecated config: 'webpack_loader' should be renamed to 'javascript_transpiler'"
131
+ end
132
+
133
+ if config_file.include?("bundler:")
134
+ @warnings << "Deprecated config: 'bundler' should be renamed to 'assets_bundler'"
135
+ end
136
+ rescue => e
137
+ # Ignore read errors as config file check already handles missing file
138
+ end
139
+
140
+ def check_version_consistency
141
+ return unless package_json_exists?
142
+
143
+ # Check if shakapacker npm package version matches gem version
144
+ package_json = read_package_json
145
+ npm_version = package_json.dig("dependencies", "shakapacker") ||
146
+ package_json.dig("devDependencies", "shakapacker")
147
+
148
+ if npm_version
149
+ gem_version = Shakapacker::VERSION rescue nil
150
+ if gem_version && !versions_compatible?(gem_version, npm_version)
151
+ @warnings << "Version mismatch: shakapacker gem is #{gem_version} but npm package is #{npm_version}"
152
+ end
153
+ end
154
+
155
+ # Check if ensure_consistent_versioning is enabled and warn if versions might mismatch
156
+ if config.ensure_consistent_versioning?
157
+ @info << "Version consistency checking is enabled"
158
+ end
159
+ end
160
+
161
+ def check_environment_consistency
162
+ rails_env = defined?(Rails) ? Rails.env : ENV["RAILS_ENV"]
163
+ node_env = ENV["NODE_ENV"]
164
+
165
+ if rails_env && node_env && rails_env != node_env
166
+ @warnings << "Environment mismatch: Rails.env is '#{rails_env}' but NODE_ENV is '#{node_env}'"
167
+ end
168
+
169
+ # Check SHAKAPACKER_ASSET_HOST for production
170
+ if rails_env == "production" && ENV["SHAKAPACKER_ASSET_HOST"].nil?
171
+ @info << "SHAKAPACKER_ASSET_HOST not set - assets will be served from the application host"
172
+ end
173
+ end
174
+
175
+ def check_sri_dependencies
176
+ return unless config.data.dig(:integrity, :enabled)
177
+
178
+ bundler = config.assets_bundler
179
+ if bundler == "webpack"
180
+ unless package_installed?("webpack-subresource-integrity")
181
+ @issues << "SRI is enabled but 'webpack-subresource-integrity' is not installed"
182
+ end
183
+ end
184
+
185
+ # Validate hash functions
186
+ hash_functions = config.data.dig(:integrity, :hash_functions) || ["sha384"]
187
+ invalid_functions = hash_functions - ["sha256", "sha384", "sha512"]
188
+ unless invalid_functions.empty?
189
+ @issues << "Invalid SRI hash functions: #{invalid_functions.join(', ')}"
190
+ end
191
+ end
192
+
193
+ def check_peer_dependencies
194
+ return unless package_json_exists?
195
+
196
+ bundler = config.assets_bundler
197
+ package_json = read_package_json
198
+ all_deps = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
199
+
200
+ if bundler == "webpack"
201
+ check_webpack_peer_deps(all_deps)
202
+ elsif bundler == "rspack"
203
+ check_rspack_peer_deps(all_deps)
204
+ end
205
+
206
+ # Check for conflicting installations
207
+ if package_installed?("webpack") && package_installed?("@rspack/core")
208
+ @warnings << "Both webpack and rspack are installed - ensure assets_bundler is set correctly"
209
+ end
210
+ end
211
+
212
+ def check_webpack_peer_deps(deps)
213
+ essential_webpack = {
214
+ "webpack" => "^5.76.0",
215
+ "webpack-cli" => "^4.9.2 || ^5.0.0"
216
+ }
217
+
218
+ essential_webpack.each do |package, version|
219
+ unless deps[package]
220
+ @issues << "Missing essential webpack dependency: #{package} (#{version})"
221
+ end
222
+ end
223
+ end
224
+
225
+ def check_rspack_peer_deps(deps)
226
+ essential_rspack = {
227
+ "@rspack/cli" => "^1.0.0",
228
+ "@rspack/core" => "^1.0.0"
229
+ }
230
+
231
+ essential_rspack.each do |package, version|
232
+ unless deps[package]
233
+ @issues << "Missing essential rspack dependency: #{package} (#{version})"
234
+ end
235
+ end
236
+ end
237
+
238
+ def check_windows_platform
239
+ if Gem.win_platform?
240
+ @info << "Windows detected: You may need to run shakapacker scripts with 'ruby bin/shakapacker'"
241
+
242
+ # Check for case sensitivity issues
243
+ if File.exist?(root_path.join("App")) || File.exist?(root_path.join("APP"))
244
+ @warnings << "Potential case sensitivity issue detected on Windows filesystem"
245
+ end
246
+ end
247
+ end
248
+
249
+ def check_assets_compilation
250
+ manifest_path = config.manifest_path
251
+
252
+ if manifest_path.exist?
253
+ # Check if manifest is recent (within last 24 hours)
254
+ manifest_age_hours = (Time.now - File.mtime(manifest_path)) / 3600
255
+
256
+ if manifest_age_hours > 24
257
+ @info << "Assets were last compiled #{manifest_age_hours.round} hours ago. Consider recompiling if you've made changes."
258
+ end
259
+
260
+ # Check if source files are newer than manifest
261
+ source_files = Dir.glob(File.join(config.source_path, "**/*.{js,jsx,ts,tsx,css,scss,sass}"))
262
+ if source_files.any?
263
+ newest_source = source_files.map { |f| File.mtime(f) }.max
264
+ if newest_source > File.mtime(manifest_path)
265
+ @warnings << "Source files have been modified after last asset compilation. Run 'rails assets:precompile'"
266
+ end
267
+ end
268
+ else
269
+ rails_env = defined?(Rails) ? Rails.env : ENV["RAILS_ENV"]
270
+ if rails_env == "production"
271
+ @issues << "No compiled assets found (manifest.json missing). Run 'rails assets:precompile'"
272
+ else
273
+ @info << "Assets not yet compiled. Run 'rails assets:precompile' or start the dev server"
274
+ end
275
+ end
276
+ end
277
+
278
+ def check_legacy_webpacker_files
279
+ legacy_files = [
280
+ "config/webpacker.yml",
281
+ "config/webpack/webpacker.yml",
282
+ "bin/webpack",
283
+ "bin/webpack-dev-server"
284
+ ]
285
+
286
+ legacy_files.each do |file|
287
+ file_path = root_path.join(file)
288
+ if file_path.exist?
289
+ @warnings << "Legacy webpacker file found: #{file} - consider removing after migration"
290
+ end
291
+ end
292
+ end
293
+
294
+ def check_node_installation
295
+ stdout, stderr, status = Open3.capture3("node", "--version")
296
+
297
+ if status.success?
298
+ node_version = stdout.strip
299
+ # Check minimum Node version (14.0.0 for modern tooling)
300
+ version_match = node_version.match(/v(\d+)\.(\d+)\.(\d+)/)
301
+ if version_match
302
+ major = version_match[1].to_i
303
+ if major < 14
304
+ @warnings << "Node.js version #{node_version} is outdated. Recommend upgrading to v14 or higher"
305
+ end
306
+ end
307
+ else
308
+ @issues << "Node.js command failed: #{stderr}"
309
+ end
310
+ rescue Errno::ENOENT
311
+ @issues << "Node.js is not installed or not in PATH"
312
+ rescue Errno::EACCES
313
+ @issues << "Permission denied when checking Node.js version"
314
+ rescue StandardError => e
315
+ @warnings << "Unable to check Node.js version: #{e.message}"
316
+ end
317
+
318
+ def check_package_manager
319
+ unless package_manager
320
+ @issues << "No package manager lock file found (package-lock.json, yarn.lock, pnpm-lock.yaml, or bun.lockb)"
321
+ end
322
+ end
323
+
324
+ def check_binstub
325
+ binstub_path = root_path.join("bin/shakapacker")
326
+ unless binstub_path.exist?
327
+ @warnings << "Shakapacker binstub not found at bin/shakapacker. Run 'rails shakapacker:binstubs' to create it."
328
+ end
329
+ end
330
+
331
+ def check_javascript_transpiler_dependencies
332
+ transpiler = config.javascript_transpiler
333
+
334
+ # Default to SWC for v9+ if not configured
335
+ if transpiler.nil?
336
+ @info << "No javascript_transpiler configured - defaulting to SWC (20x faster than Babel)"
337
+ transpiler = "swc"
338
+ end
339
+
340
+ return if transpiler == "none"
341
+
342
+ bundler = config.assets_bundler
343
+
344
+ case transpiler
345
+ when "babel"
346
+ check_babel_dependencies
347
+ check_babel_performance_suggestion
348
+ when "swc"
349
+ check_swc_dependencies(bundler)
350
+ when "esbuild"
351
+ check_esbuild_dependencies
352
+ else
353
+ # Generic check for other transpilers
354
+ loader_name = "#{transpiler}-loader"
355
+ unless package_installed?(loader_name)
356
+ @issues << "Missing required dependency '#{loader_name}' for JavaScript transpiler '#{transpiler}'"
357
+ end
358
+ end
359
+
360
+ check_transpiler_config_consistency
361
+ end
362
+
363
+ def check_babel_dependencies
364
+ unless package_installed?("babel-loader")
365
+ @issues << "Missing required dependency 'babel-loader' for JavaScript transpiler 'babel'"
366
+ end
367
+ unless package_installed?("@babel/core")
368
+ @issues << "Missing required dependency '@babel/core' for Babel transpiler"
369
+ end
370
+ unless package_installed?("@babel/preset-env")
371
+ @issues << "Missing required dependency '@babel/preset-env' for Babel transpiler"
372
+ end
373
+ end
374
+
375
+ def check_babel_performance_suggestion
376
+ @info << "Consider switching to SWC for 20x faster compilation. Set javascript_transpiler: 'swc' in shakapacker.yml"
377
+ end
378
+
379
+ def check_swc_dependencies(bundler)
380
+ if bundler == "webpack"
381
+ unless package_installed?("@swc/core")
382
+ @issues << "Missing required dependency '@swc/core' for SWC transpiler"
383
+ end
384
+ unless package_installed?("swc-loader")
385
+ @issues << "Missing required dependency 'swc-loader' for SWC with webpack"
386
+ end
387
+ elsif bundler == "rspack"
388
+ # Rspack has built-in SWC support
389
+ @info << "Rspack has built-in SWC support - no additional loaders needed"
390
+ if package_installed?("swc-loader")
391
+ @warnings << "swc-loader is not needed with Rspack (SWC is built-in) - consider removing it"
392
+ end
393
+ end
394
+ end
395
+
396
+ def check_esbuild_dependencies
397
+ unless package_installed?("esbuild")
398
+ @issues << "Missing required dependency 'esbuild' for esbuild transpiler"
399
+ end
400
+ unless package_installed?("esbuild-loader")
401
+ @issues << "Missing required dependency 'esbuild-loader' for esbuild transpiler"
402
+ end
403
+ end
404
+
405
+ def check_transpiler_config_consistency
406
+ babel_configs = [
407
+ root_path.join(".babelrc"),
408
+ root_path.join(".babelrc.js"),
409
+ root_path.join(".babelrc.json"),
410
+ root_path.join("babel.config.js"),
411
+ root_path.join("babel.config.json")
412
+ ]
413
+
414
+ babel_config_exists = babel_configs.any?(&:exist?)
415
+
416
+ # Check if package.json has babel config
417
+ if package_json_exists?
418
+ package_json = read_package_json
419
+ babel_config_exists ||= package_json.key?("babel")
420
+ end
421
+
422
+ transpiler = config.javascript_transpiler
423
+
424
+ if babel_config_exists && transpiler != "babel"
425
+ @warnings << "Babel configuration files found but javascript_transpiler is '#{transpiler}'. Consider removing Babel configs or setting javascript_transpiler: 'babel'"
426
+ end
427
+
428
+ # Check for redundant dependencies
429
+ if transpiler == "swc" && package_installed?("babel-loader")
430
+ @warnings << "Both SWC and Babel dependencies are installed. Consider removing Babel dependencies to reduce node_modules size"
431
+ end
432
+
433
+ if transpiler == "esbuild" && package_installed?("babel-loader")
434
+ @warnings << "Both esbuild and Babel dependencies are installed. Consider removing Babel dependencies to reduce node_modules size"
435
+ end
436
+
437
+ # Check for SWC configuration conflicts
438
+ if transpiler == "swc"
439
+ check_swc_config_conflicts
440
+ end
441
+ end
442
+
443
+ def check_swc_config_conflicts
444
+ swcrc_path = root_path.join(".swcrc")
445
+ swc_config_path = root_path.join("config/swc.config.js")
446
+
447
+ if swcrc_path.exist?
448
+ @warnings << "SWC configuration: .swcrc file detected. This file completely overrides Shakapacker's default SWC settings and may cause build failures. " \
449
+ "Please migrate to config/swc.config.js which properly merges with Shakapacker defaults. " \
450
+ "To migrate: Move your custom settings from .swcrc to config/swc.config.js (see docs for format). " \
451
+ "See: https://github.com/shakacode/shakapacker/blob/main/docs/using_swc_loader.md"
452
+ end
453
+
454
+ if swc_config_path.exist?
455
+ @info << "SWC configuration: Using config/swc.config.js (recommended). This config is merged with Shakapacker's defaults."
456
+ end
457
+ end
458
+
459
+ def check_css_dependencies
460
+ check_dependency("css-loader", @issues, "CSS")
461
+ check_dependency("style-loader", @issues, "CSS (style-loader)")
462
+ check_optional_dependency("mini-css-extract-plugin", @warnings, "CSS extraction")
463
+ end
464
+
465
+ def check_css_modules_configuration
466
+ # Check for CSS module files in the project
467
+ return unless config_exists?
468
+
469
+ source_path = config.source_path
470
+ return unless source_path.exist?
471
+
472
+ # Performance optimization: Just check if ANY CSS module file exists
473
+ # Using .first with early return is much faster than globbing all files
474
+ css_module_exists = Dir.glob(File.join(source_path, "**/*.module.{css,scss,sass}")).first
475
+ return unless css_module_exists
476
+
477
+ # Check webpack configuration for CSS modules settings
478
+ webpack_config_paths = [
479
+ root_path.join("config/webpack/webpack.config.js"),
480
+ root_path.join("config/webpack/webpack.config.ts"),
481
+ root_path.join("config/webpack/commonWebpackConfig.js"),
482
+ root_path.join("config/webpack/commonWebpackConfig.ts")
483
+ ]
484
+
485
+ webpack_config_paths.each do |config_path|
486
+ next unless config_path.exist?
487
+
488
+ config_content = File.read(config_path)
489
+
490
+ # Check for the invalid configuration: namedExport: true with exportLocalsConvention: 'camelCase'
491
+ if config_content.match(/namedExport\s*:\s*true/) && config_content.match(/exportLocalsConvention\s*:\s*['"]camelCase['"]/)
492
+ @issues << "CSS Modules: Invalid configuration detected in #{config_path.relative_path_from(root_path)}"
493
+ @issues << " Using exportLocalsConvention: 'camelCase' with namedExport: true will cause build errors"
494
+ @issues << " Change to 'camelCaseOnly' or 'dashesOnly'. See docs/v9_upgrade.md for details"
495
+ end
496
+
497
+ # Warn if CSS modules are used but no configuration is found
498
+ if !config_content.match(/namedExport/) && !config_content.match(/exportLocalsConvention/)
499
+ @info << "CSS module files found but no explicit CSS modules configuration detected"
500
+ @info << " v9 defaults: namedExport: true, exportLocalsConvention: 'camelCaseOnly'"
501
+ end
502
+ end
503
+
504
+ # Check for common v8 to v9 migration issues
505
+ check_css_modules_import_patterns
506
+ rescue => e
507
+ # Don't fail doctor if CSS modules check has issues
508
+ @warnings << "Unable to validate CSS modules configuration: #{e.message}"
509
+ end
510
+
511
+ def check_css_modules_import_patterns
512
+ # Look for JavaScript/TypeScript files that might have v8-style imports
513
+ source_path = config.source_path
514
+
515
+ # Use lazy evaluation with Enumerator to avoid loading all file paths into memory
516
+ # Stop after checking 50 files or finding a match
517
+ v8_pattern = /import\s+\w+\s+from\s+['"][^'"]*\.module\.(css|scss|sass)['"]/
518
+
519
+ Dir.glob(File.join(source_path, "**/*.{js,jsx,ts,tsx}")).lazy.take(50).each do |file|
520
+ # Read file and check for v8 pattern
521
+ content = File.read(file)
522
+
523
+ # Check for v8 default import pattern with .module.css
524
+ if v8_pattern.match?(content)
525
+ @warnings << "Potential v8-style CSS module imports detected (using default import)"
526
+ @warnings << " v9 uses named exports. Update to: import { className } from './styles.module.css'"
527
+ @warnings << " Or use: import * as styles from './styles.module.css' (TypeScript)"
528
+ @warnings << " See docs/v9_upgrade.md for migration guide"
529
+ break # Stop after finding first occurrence
530
+ end
531
+ end
532
+ rescue => e
533
+ # Don't fail doctor if import pattern check has issues
534
+ end
535
+
536
+ def check_bundler_dependencies
537
+ bundler = config.assets_bundler
538
+ case bundler
539
+ when "webpack"
540
+ check_dependency("webpack", @issues, "webpack")
541
+ check_dependency("webpack-cli", @issues, "webpack CLI")
542
+ when "rspack"
543
+ check_dependency("@rspack/core", @issues, "Rspack")
544
+ check_dependency("@rspack/cli", @issues, "Rspack CLI")
545
+ end
546
+ end
547
+
548
+ def check_file_type_dependencies
549
+ source_path = config.source_path
550
+ return unless source_path.exist?
551
+
552
+ check_typescript_dependencies if typescript_files_exist?
553
+ check_sass_dependencies if sass_files_exist?
554
+ check_less_dependencies if less_files_exist?
555
+ check_stylus_dependencies if stylus_files_exist?
556
+ check_postcss_dependencies if postcss_config_exists?
557
+ end
558
+
559
+ def check_typescript_dependencies
560
+ transpiler = config.javascript_transpiler
561
+ if transpiler == "babel"
562
+ check_optional_dependency("@babel/preset-typescript", @warnings, "TypeScript with Babel")
563
+ elsif transpiler != "esbuild" && transpiler != "swc"
564
+ check_optional_dependency("ts-loader", @warnings, "TypeScript")
565
+ end
566
+ end
567
+
568
+ def check_sass_dependencies
569
+ check_dependency("sass-loader", @issues, "Sass/SCSS")
570
+ check_dependency("sass", @issues, "Sass/SCSS (sass package)")
571
+ end
572
+
573
+ def check_less_dependencies
574
+ check_dependency("less-loader", @issues, "Less")
575
+ check_dependency("less", @issues, "Less (less package)")
576
+ end
577
+
578
+ def check_stylus_dependencies
579
+ check_dependency("stylus-loader", @issues, "Stylus")
580
+ check_dependency("stylus", @issues, "Stylus (stylus package)")
581
+ end
582
+
583
+ def check_postcss_dependencies
584
+ check_dependency("postcss", @issues, "PostCSS")
585
+ check_dependency("postcss-loader", @issues, "PostCSS")
586
+ end
587
+
588
+ def check_dependency(package_name, issues_array, description)
589
+ unless package_installed?(package_name)
590
+ issues_array << "Missing required dependency '#{package_name}' for #{description}"
591
+ end
592
+ end
593
+
594
+ def check_optional_dependency(package_name, warnings_array, description)
595
+ unless package_installed?(package_name)
596
+ warnings_array << "Optional dependency '#{package_name}' for #{description} is not installed"
597
+ end
598
+ end
599
+
600
+ def package_installed?(package_name)
601
+ return false unless package_json_exists?
602
+
603
+ package_json = read_package_json
604
+ dependencies = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
605
+ dependencies.key?(package_name)
606
+ end
607
+
608
+ def package_json_exists?
609
+ package_json_path.exist?
610
+ end
611
+
612
+ def package_json_path
613
+ root_path.join("package.json")
614
+ end
615
+
616
+ def read_package_json
617
+ @package_json ||= begin
618
+ JSON.parse(File.read(package_json_path))
619
+ rescue JSON::ParserError
620
+ {}
621
+ end
622
+ end
623
+
624
+ def config_exists?
625
+ config.config_path.exist?
626
+ end
627
+
628
+ def typescript_files_exist?
629
+ # Use .first for early exit optimization
630
+ !Dir.glob(File.join(config.source_path, "**/*.{ts,tsx}")).first.nil?
631
+ end
632
+
633
+ def sass_files_exist?
634
+ !Dir.glob(File.join(config.source_path, "**/*.{sass,scss}")).first.nil?
635
+ end
636
+
637
+ def less_files_exist?
638
+ !Dir.glob(File.join(config.source_path, "**/*.less")).first.nil?
639
+ end
640
+
641
+ def stylus_files_exist?
642
+ !Dir.glob(File.join(config.source_path, "**/*.{styl,stylus}")).first.nil?
643
+ end
644
+
645
+ def postcss_config_exists?
646
+ root_path.join("postcss.config.js").exist?
647
+ end
648
+
649
+ def package_manager
650
+ @package_manager ||= detect_package_manager
651
+ end
652
+
653
+ def detect_package_manager
654
+ return "bun" if File.exist?(root_path.join("bun.lockb"))
655
+ return "pnpm" if File.exist?(root_path.join("pnpm-lock.yaml"))
656
+ return "yarn" if File.exist?(root_path.join("yarn.lock"))
657
+ return "npm" if File.exist?(root_path.join("package-lock.json"))
658
+ nil
659
+ end
660
+
661
+ def versions_compatible?(gem_version, npm_version)
662
+ # Handle pre-release versions and ranges properly
663
+ npm_clean = npm_version.gsub(/[\^~]/, "")
664
+
665
+ # Extract version without pre-release suffix
666
+ gem_base = gem_version.split("-").first
667
+ npm_base = npm_clean.split("-").first
668
+
669
+ # Compare major versions
670
+ gem_major = gem_base.split(".").first
671
+ npm_major = npm_base.split(".").first
672
+
673
+ if gem_major != npm_major
674
+ return false
675
+ end
676
+
677
+ # For same major version, check if npm version satisfies gem version
678
+ begin
679
+ # Use semantic versioning if available
680
+ if defined?(SemanticRange)
681
+ SemanticRange.satisfies?(gem_version, npm_version)
682
+ else
683
+ gem_major == npm_major
684
+ end
685
+ rescue StandardError
686
+ # Fallback to simple major version comparison
687
+ gem_major == npm_major
688
+ end
689
+ end
690
+
691
+ def report_results
692
+ reporter = Reporter.new(self)
693
+ reporter.print_report
694
+ exit(1) unless success?
695
+ end
696
+
697
+ class Reporter
698
+ attr_reader :doctor
699
+
700
+ def initialize(doctor)
701
+ @doctor = doctor
702
+ end
703
+
704
+ def print_report
705
+ print_header
706
+ print_checks
707
+ print_summary
708
+ end
709
+
710
+ private
711
+
712
+ def print_header
713
+ puts "Running Shakapacker doctor..."
714
+ puts "=" * 60
715
+ end
716
+
717
+ def print_checks
718
+ if doctor.config.config_path.exist?
719
+ puts "✓ Configuration file found"
720
+ print_transpiler_status
721
+ print_bundler_status
722
+ print_css_status
723
+ end
724
+
725
+ print_node_status
726
+ print_package_manager_status
727
+ print_binstub_status
728
+ print_info_messages
729
+ end
730
+
731
+ def print_transpiler_status
732
+ transpiler = doctor.config.javascript_transpiler
733
+ return if transpiler.nil? || transpiler == "none"
734
+
735
+ loader_name = "#{transpiler}-loader"
736
+ if doctor.send(:package_installed?, loader_name)
737
+ puts "✓ JavaScript transpiler: #{loader_name} is installed"
738
+ end
739
+ end
740
+
741
+ def print_bundler_status
742
+ bundler = doctor.config.assets_bundler
743
+ case bundler
744
+ when "webpack"
745
+ print_package_status("webpack", "webpack")
746
+ print_package_status("webpack-cli", "webpack CLI")
747
+ when "rspack"
748
+ print_package_status("@rspack/core", "Rspack")
749
+ print_package_status("@rspack/cli", "Rspack CLI")
750
+ end
751
+ end
752
+
753
+ def print_css_status
754
+ print_package_status("css-loader", "CSS")
755
+ print_package_status("style-loader", "CSS (style-loader)")
756
+ print_package_status("mini-css-extract-plugin", "CSS extraction (optional)")
757
+ end
758
+
759
+ def print_package_status(package_name, description)
760
+ if doctor.send(:package_installed?, package_name)
761
+ puts "✓ #{description}: #{package_name} is installed"
762
+ end
763
+ end
764
+
765
+ def print_node_status
766
+ begin
767
+ stdout, stderr, status = Open3.capture3("node", "--version")
768
+ if status.success?
769
+ puts "✓ Node.js #{stdout.strip} found"
770
+ end
771
+ rescue Errno::ENOENT, Errno::EACCES, StandardError
772
+ # Error already added to issues
773
+ end
774
+ end
775
+
776
+ def print_package_manager_status
777
+ package_manager = doctor.send(:package_manager)
778
+ if package_manager
779
+ puts "✓ Package manager: #{package_manager}"
780
+ end
781
+ end
782
+
783
+ def print_binstub_status
784
+ binstub_path = doctor.root_path.join("bin/shakapacker")
785
+ if binstub_path.exist?
786
+ puts "✓ Shakapacker binstub found"
787
+ end
788
+ end
789
+
790
+ def print_info_messages
791
+ return if doctor.info.empty?
792
+
793
+ puts "\nℹ️ Information:"
794
+ doctor.info.each do |info|
795
+ puts " • #{info}"
796
+ end
797
+ end
798
+
799
+ def print_summary
800
+ puts "=" * 60
801
+
802
+ if doctor.issues.empty? && doctor.warnings.empty?
803
+ puts "✅ No issues found! Shakapacker appears to be configured correctly."
804
+ else
805
+ print_issues if doctor.issues.any?
806
+ print_warnings if doctor.warnings.any?
807
+ print_fix_instructions
808
+ end
809
+ end
810
+
811
+ def print_issues
812
+ puts "❌ Issues found (#{doctor.issues.length}):"
813
+ doctor.issues.each_with_index do |issue, index|
814
+ puts " #{index + 1}. #{issue}"
815
+ end
816
+ puts ""
817
+ end
818
+
819
+ def print_warnings
820
+ puts "⚠️ Warnings (#{doctor.warnings.length}):"
821
+ doctor.warnings.each_with_index do |warning, index|
822
+ puts " #{index + 1}. #{warning}"
823
+ end
824
+ puts ""
825
+ end
826
+
827
+ def print_fix_instructions
828
+ package_manager = doctor.send(:package_manager)
829
+ puts "To fix missing dependencies, run:"
830
+ puts " #{package_manager_install_command(package_manager)}"
831
+ end
832
+
833
+ def package_manager_install_command(manager)
834
+ case manager
835
+ when "bun" then "bun add -D [package-name]"
836
+ when "pnpm" then "pnpm add -D [package-name]"
837
+ when "yarn" then "yarn add -D [package-name]"
838
+ when "npm" then "npm install --save-dev [package-name]"
839
+ else "npm install --save-dev [package-name]"
840
+ end
841
+ end
842
+ end
843
+ end
844
+ end