shakapacker 9.0.0.beta.4 → 9.0.0.beta.6

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