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