shakapacker 9.0.0.beta.4 → 9.0.0.beta.5

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dummy.yml +4 -0
  3. data/.github/workflows/generator.yml +7 -0
  4. data/.github/workflows/node.yml +22 -0
  5. data/.github/workflows/ruby.yml +11 -0
  6. data/.github/workflows/test-bundlers.yml +18 -0
  7. data/.gitignore +20 -0
  8. data/.yalcignore +26 -0
  9. data/CHANGELOG.md +58 -40
  10. data/Gemfile.lock +1 -1
  11. data/README.md +3 -1
  12. data/docs/typescript.md +99 -0
  13. data/docs/v9_upgrade.md +14 -1
  14. data/lib/install/template.rb +8 -1
  15. data/lib/shakapacker/configuration.rb +58 -1
  16. data/lib/shakapacker/doctor.rb +752 -0
  17. data/lib/shakapacker/swc_migrator.rb +292 -0
  18. data/lib/shakapacker/version.rb +1 -1
  19. data/lib/shakapacker.rb +1 -0
  20. data/lib/tasks/shakapacker/doctor.rake +8 -0
  21. data/lib/tasks/shakapacker/migrate_to_swc.rake +13 -0
  22. data/lib/tasks/shakapacker.rake +1 -0
  23. data/package/config.ts +162 -0
  24. data/package/{dev_server.js → dev_server.ts} +8 -5
  25. data/package/env.ts +67 -0
  26. data/package/environments/base.js +21 -31
  27. data/package/environments/base.ts +137 -0
  28. data/package/index.d.ts +3 -150
  29. data/package/{index.js → index.ts} +17 -8
  30. data/package/loaders.d.ts +27 -0
  31. data/package/types.ts +108 -0
  32. data/package/utils/configPath.ts +6 -0
  33. data/package/utils/{debug.js → debug.ts} +7 -7
  34. data/package/utils/defaultConfigPath.ts +4 -0
  35. data/package/utils/errorHelpers.ts +77 -0
  36. data/package/utils/{getStyleRule.js → getStyleRule.ts} +17 -20
  37. data/package/utils/helpers.ts +85 -0
  38. data/package/utils/{inliningCss.js → inliningCss.ts} +3 -3
  39. data/package/utils/{requireOrError.js → requireOrError.ts} +2 -2
  40. data/package/utils/snakeToCamelCase.ts +5 -0
  41. data/package/utils/typeGuards.ts +228 -0
  42. data/package/utils/{validateDependencies.js → validateDependencies.ts} +4 -4
  43. data/package/webpack-types.d.ts +32 -0
  44. data/package/webpackDevServerConfig.ts +117 -0
  45. data/package.json +6 -2
  46. data/test/typescript/build.test.js +117 -0
  47. data/tsconfig.json +39 -0
  48. data/yarn.lock +1 -1
  49. metadata +31 -17
  50. data/package/config.js +0 -80
  51. data/package/env.js +0 -48
  52. data/package/utils/configPath.js +0 -4
  53. data/package/utils/defaultConfigPath.js +0 -2
  54. data/package/utils/helpers.js +0 -127
  55. data/package/utils/snakeToCamelCase.js +0 -5
  56. data/package/utils/validateCssModulesConfig.js +0 -91
  57. data/package/webpackDevServerConfig.js +0 -73
@@ -0,0 +1,752 @@
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
+ "webpack-merge" => "^5.8.0 || ^6.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
+ end
437
+
438
+ def check_css_dependencies
439
+ check_dependency("css-loader", @issues, "CSS")
440
+ check_dependency("style-loader", @issues, "CSS (style-loader)")
441
+ check_optional_dependency("mini-css-extract-plugin", @warnings, "CSS extraction")
442
+ end
443
+
444
+ def check_bundler_dependencies
445
+ bundler = config.assets_bundler
446
+ case bundler
447
+ when "webpack"
448
+ check_dependency("webpack", @issues, "webpack")
449
+ check_dependency("webpack-cli", @issues, "webpack CLI")
450
+ when "rspack"
451
+ check_dependency("@rspack/core", @issues, "Rspack")
452
+ check_dependency("@rspack/cli", @issues, "Rspack CLI")
453
+ end
454
+ end
455
+
456
+ def check_file_type_dependencies
457
+ source_path = config.source_path
458
+ return unless source_path.exist?
459
+
460
+ check_typescript_dependencies if typescript_files_exist?
461
+ check_sass_dependencies if sass_files_exist?
462
+ check_less_dependencies if less_files_exist?
463
+ check_stylus_dependencies if stylus_files_exist?
464
+ check_postcss_dependencies if postcss_config_exists?
465
+ end
466
+
467
+ def check_typescript_dependencies
468
+ transpiler = config.javascript_transpiler
469
+ if transpiler == "babel"
470
+ check_optional_dependency("@babel/preset-typescript", @warnings, "TypeScript with Babel")
471
+ elsif transpiler != "esbuild" && transpiler != "swc"
472
+ check_optional_dependency("ts-loader", @warnings, "TypeScript")
473
+ end
474
+ end
475
+
476
+ def check_sass_dependencies
477
+ check_dependency("sass-loader", @issues, "Sass/SCSS")
478
+ check_dependency("sass", @issues, "Sass/SCSS (sass package)")
479
+ end
480
+
481
+ def check_less_dependencies
482
+ check_dependency("less-loader", @issues, "Less")
483
+ check_dependency("less", @issues, "Less (less package)")
484
+ end
485
+
486
+ def check_stylus_dependencies
487
+ check_dependency("stylus-loader", @issues, "Stylus")
488
+ check_dependency("stylus", @issues, "Stylus (stylus package)")
489
+ end
490
+
491
+ def check_postcss_dependencies
492
+ check_dependency("postcss", @issues, "PostCSS")
493
+ check_dependency("postcss-loader", @issues, "PostCSS")
494
+ end
495
+
496
+ def check_dependency(package_name, issues_array, description)
497
+ unless package_installed?(package_name)
498
+ issues_array << "Missing required dependency '#{package_name}' for #{description}"
499
+ end
500
+ end
501
+
502
+ def check_optional_dependency(package_name, warnings_array, description)
503
+ unless package_installed?(package_name)
504
+ warnings_array << "Optional dependency '#{package_name}' for #{description} is not installed"
505
+ end
506
+ end
507
+
508
+ def package_installed?(package_name)
509
+ return false unless package_json_exists?
510
+
511
+ package_json = read_package_json
512
+ dependencies = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
513
+ dependencies.key?(package_name)
514
+ end
515
+
516
+ def package_json_exists?
517
+ package_json_path.exist?
518
+ end
519
+
520
+ def package_json_path
521
+ root_path.join("package.json")
522
+ end
523
+
524
+ def read_package_json
525
+ @package_json ||= begin
526
+ JSON.parse(File.read(package_json_path))
527
+ rescue JSON::ParserError
528
+ {}
529
+ end
530
+ end
531
+
532
+ def config_exists?
533
+ config.config_path.exist?
534
+ end
535
+
536
+ def typescript_files_exist?
537
+ # Use .first for early exit optimization
538
+ !Dir.glob(File.join(config.source_path, "**/*.{ts,tsx}")).first.nil?
539
+ end
540
+
541
+ def sass_files_exist?
542
+ !Dir.glob(File.join(config.source_path, "**/*.{sass,scss}")).first.nil?
543
+ end
544
+
545
+ def less_files_exist?
546
+ !Dir.glob(File.join(config.source_path, "**/*.less")).first.nil?
547
+ end
548
+
549
+ def stylus_files_exist?
550
+ !Dir.glob(File.join(config.source_path, "**/*.{styl,stylus}")).first.nil?
551
+ end
552
+
553
+ def postcss_config_exists?
554
+ root_path.join("postcss.config.js").exist?
555
+ end
556
+
557
+ def package_manager
558
+ @package_manager ||= detect_package_manager
559
+ end
560
+
561
+ def detect_package_manager
562
+ return "bun" if File.exist?(root_path.join("bun.lockb"))
563
+ return "pnpm" if File.exist?(root_path.join("pnpm-lock.yaml"))
564
+ return "yarn" if File.exist?(root_path.join("yarn.lock"))
565
+ return "npm" if File.exist?(root_path.join("package-lock.json"))
566
+ nil
567
+ end
568
+
569
+ def versions_compatible?(gem_version, npm_version)
570
+ # Handle pre-release versions and ranges properly
571
+ npm_clean = npm_version.gsub(/[\^~]/, "")
572
+
573
+ # Extract version without pre-release suffix
574
+ gem_base = gem_version.split("-").first
575
+ npm_base = npm_clean.split("-").first
576
+
577
+ # Compare major versions
578
+ gem_major = gem_base.split(".").first
579
+ npm_major = npm_base.split(".").first
580
+
581
+ if gem_major != npm_major
582
+ return false
583
+ end
584
+
585
+ # For same major version, check if npm version satisfies gem version
586
+ begin
587
+ # Use semantic versioning if available
588
+ if defined?(SemanticRange)
589
+ SemanticRange.satisfies?(gem_version, npm_version)
590
+ else
591
+ gem_major == npm_major
592
+ end
593
+ rescue StandardError
594
+ # Fallback to simple major version comparison
595
+ gem_major == npm_major
596
+ end
597
+ end
598
+
599
+ def report_results
600
+ reporter = Reporter.new(self)
601
+ reporter.print_report
602
+ exit(1) unless success?
603
+ end
604
+
605
+ class Reporter
606
+ attr_reader :doctor
607
+
608
+ def initialize(doctor)
609
+ @doctor = doctor
610
+ end
611
+
612
+ def print_report
613
+ print_header
614
+ print_checks
615
+ print_summary
616
+ end
617
+
618
+ private
619
+
620
+ def print_header
621
+ puts "Running Shakapacker doctor..."
622
+ puts "=" * 60
623
+ end
624
+
625
+ def print_checks
626
+ if doctor.config.config_path.exist?
627
+ puts "✓ Configuration file found"
628
+ print_transpiler_status
629
+ print_bundler_status
630
+ print_css_status
631
+ end
632
+
633
+ print_node_status
634
+ print_package_manager_status
635
+ print_binstub_status
636
+ print_info_messages
637
+ end
638
+
639
+ def print_transpiler_status
640
+ transpiler = doctor.config.javascript_transpiler
641
+ return if transpiler.nil? || transpiler == "none"
642
+
643
+ loader_name = "#{transpiler}-loader"
644
+ if doctor.send(:package_installed?, loader_name)
645
+ puts "✓ JavaScript transpiler: #{loader_name} is installed"
646
+ end
647
+ end
648
+
649
+ def print_bundler_status
650
+ bundler = doctor.config.assets_bundler
651
+ case bundler
652
+ when "webpack"
653
+ print_package_status("webpack", "webpack")
654
+ print_package_status("webpack-cli", "webpack CLI")
655
+ when "rspack"
656
+ print_package_status("@rspack/core", "Rspack")
657
+ print_package_status("@rspack/cli", "Rspack CLI")
658
+ end
659
+ end
660
+
661
+ def print_css_status
662
+ print_package_status("css-loader", "CSS")
663
+ print_package_status("style-loader", "CSS (style-loader)")
664
+ print_package_status("mini-css-extract-plugin", "CSS extraction (optional)")
665
+ end
666
+
667
+ def print_package_status(package_name, description)
668
+ if doctor.send(:package_installed?, package_name)
669
+ puts "✓ #{description}: #{package_name} is installed"
670
+ end
671
+ end
672
+
673
+ def print_node_status
674
+ begin
675
+ stdout, stderr, status = Open3.capture3("node", "--version")
676
+ if status.success?
677
+ puts "✓ Node.js #{stdout.strip} found"
678
+ end
679
+ rescue Errno::ENOENT, Errno::EACCES, StandardError
680
+ # Error already added to issues
681
+ end
682
+ end
683
+
684
+ def print_package_manager_status
685
+ package_manager = doctor.send(:package_manager)
686
+ if package_manager
687
+ puts "✓ Package manager: #{package_manager}"
688
+ end
689
+ end
690
+
691
+ def print_binstub_status
692
+ binstub_path = doctor.root_path.join("bin/shakapacker")
693
+ if binstub_path.exist?
694
+ puts "✓ Shakapacker binstub found"
695
+ end
696
+ end
697
+
698
+ def print_info_messages
699
+ return if doctor.info.empty?
700
+
701
+ puts "\nℹ️ Information:"
702
+ doctor.info.each do |info|
703
+ puts " • #{info}"
704
+ end
705
+ end
706
+
707
+ def print_summary
708
+ puts "=" * 60
709
+
710
+ if doctor.issues.empty? && doctor.warnings.empty?
711
+ puts "✅ No issues found! Shakapacker appears to be configured correctly."
712
+ else
713
+ print_issues if doctor.issues.any?
714
+ print_warnings if doctor.warnings.any?
715
+ print_fix_instructions
716
+ end
717
+ end
718
+
719
+ def print_issues
720
+ puts "❌ Issues found (#{doctor.issues.length}):"
721
+ doctor.issues.each_with_index do |issue, index|
722
+ puts " #{index + 1}. #{issue}"
723
+ end
724
+ puts ""
725
+ end
726
+
727
+ def print_warnings
728
+ puts "⚠️ Warnings (#{doctor.warnings.length}):"
729
+ doctor.warnings.each_with_index do |warning, index|
730
+ puts " #{index + 1}. #{warning}"
731
+ end
732
+ puts ""
733
+ end
734
+
735
+ def print_fix_instructions
736
+ package_manager = doctor.send(:package_manager)
737
+ puts "To fix missing dependencies, run:"
738
+ puts " #{package_manager_install_command(package_manager)}"
739
+ end
740
+
741
+ def package_manager_install_command(manager)
742
+ case manager
743
+ when "bun" then "bun add -D [package-name]"
744
+ when "pnpm" then "pnpm add -D [package-name]"
745
+ when "yarn" then "yarn add -D [package-name]"
746
+ when "npm" then "npm install --save-dev [package-name]"
747
+ else "npm install --save-dev [package-name]"
748
+ end
749
+ end
750
+ end
751
+ end
752
+ end