shakapacker 10.1.0 → 10.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.
@@ -23,6 +23,55 @@ module Shakapacker
23
23
  bin/diff-bundler-config
24
24
  ].freeze
25
25
 
26
+ PACKAGE_MANAGER_LOCKFILES = {
27
+ "bun.lockb" => "bun",
28
+ "pnpm-lock.yaml" => "pnpm",
29
+ "yarn.lock" => "yarn",
30
+ "package-lock.json" => "npm"
31
+ }.freeze
32
+
33
+ SASS_IMPLEMENTATION_PACKAGES = %w[
34
+ sass
35
+ sass-embedded
36
+ ].freeze
37
+
38
+ SASS_IMPLEMENTATION_DEPENDENCY_MESSAGE = (
39
+ "Missing required dependency 'sass' or 'sass-embedded' " \
40
+ "for Sass/SCSS implementation"
41
+ ).freeze
42
+
43
+ REQUIRED_RSPACK_DEPS = {
44
+ "@rspack/core" => "^2.0.0",
45
+ "@rspack/cli" => "^2.0.0",
46
+ "rspack-manifest-plugin" => "^5.2.2"
47
+ }.freeze
48
+
49
+ RSPACK_DEV_SERVER_DEP = {
50
+ "@rspack/dev-server" => "^2.0.0"
51
+ }.freeze
52
+
53
+ RSPACK_V2_ONLY_DEPS = REQUIRED_RSPACK_DEPS
54
+ .slice("@rspack/core", "@rspack/cli")
55
+ .merge(RSPACK_DEV_SERVER_DEP)
56
+ .freeze
57
+
58
+ OPTIONAL_RSPACK_V2_ONLY_DEPS = {
59
+ "@rspack/plugin-react-refresh" => "^2.0.0"
60
+ }.freeze
61
+
62
+ CUSTOM_HYBRID_LOADER_DEPS = %w[
63
+ babel-loader
64
+ esbuild-loader
65
+ ts-loader
66
+ ].freeze
67
+
68
+ BUNDLER_CONFIG_EXTENSIONS = %w[
69
+ ts
70
+ js
71
+ ].freeze
72
+
73
+ PACKAGE_ROOT_MARKERS = (["package.json"] + PACKAGE_MANAGER_LOCKFILES.keys + ["node_modules"]).freeze
74
+
26
75
  def initialize(config = nil, root_path = nil, options = {})
27
76
  @config = config || Shakapacker.config
28
77
  @root_path = root_path || (defined?(Rails) ? Rails.root : Pathname.new(Dir.pwd))
@@ -134,6 +183,8 @@ module Shakapacker
134
183
  end
135
184
 
136
185
  def check_config_file
186
+ report_empty_assets_bundler_env_override if empty_assets_bundler_env_override?
187
+
137
188
  unless config.config_path.exist?
138
189
  @issues << "Configuration file not found at #{config.config_path}"
139
190
  end
@@ -220,10 +271,9 @@ module Shakapacker
220
271
  def check_version_consistency
221
272
  return unless package_json_exists?
222
273
 
223
- # Check if shakapacker npm package version matches gem version
224
- package_json = read_package_json
225
- npm_version = package_json.dig("dependencies", "shakapacker") ||
226
- package_json.dig("devDependencies", "shakapacker")
274
+ # Check if shakapacker npm package version matches gem version. Use the
275
+ # flattened dependency map so a nearer package root wins across sections.
276
+ npm_version = package_json_dependency_version("shakapacker")
227
277
 
228
278
  if npm_version
229
279
  # Skip version check for github/file references
@@ -259,7 +309,7 @@ module Shakapacker
259
309
  integrity_config = config.integrity
260
310
  return unless integrity_config&.dig(:enabled)
261
311
 
262
- bundler = config.assets_bundler
312
+ bundler = assets_bundler
263
313
  if bundler == "webpack"
264
314
  unless package_installed?("webpack-subresource-integrity")
265
315
  @issues << "SRI is enabled but 'webpack-subresource-integrity' is not installed"
@@ -277,9 +327,8 @@ module Shakapacker
277
327
  def check_peer_dependencies
278
328
  return unless package_json_exists?
279
329
 
280
- bundler = config.assets_bundler
281
- package_json = read_package_json
282
- all_deps = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
330
+ bundler = assets_bundler
331
+ all_deps = declared_package_dependencies
283
332
 
284
333
  if bundler == "webpack"
285
334
  check_webpack_peer_deps(all_deps)
@@ -289,7 +338,13 @@ module Shakapacker
289
338
 
290
339
  # Check for conflicting installations
291
340
  if package_installed?("webpack") && package_installed?("@rspack/core")
292
- add_warning("Both webpack and rspack are installed - ensure assets_bundler is set correctly")
341
+ if assets_bundler_configured?
342
+ add_warning("Both webpack and rspack are installed - ensure assets_bundler is set correctly")
343
+ else
344
+ add_warning("Both webpack and rspack are installed while assets_bundler is inferred as '#{bundler}'. " \
345
+ "This can be intentional for custom hybrid webpack/Rspack setups; set assets_bundler " \
346
+ "explicitly to document the active Shakapacker-managed bundler.")
347
+ end
293
348
  end
294
349
  end
295
350
 
@@ -307,16 +362,48 @@ module Shakapacker
307
362
  end
308
363
 
309
364
  def check_rspack_peer_deps(deps)
310
- essential_rspack = {
311
- "@rspack/cli" => "^1.0.0",
312
- "@rspack/core" => "^1.0.0"
313
- }
314
-
315
- essential_rspack.each do |package, version|
316
- unless deps[package]
365
+ REQUIRED_RSPACK_DEPS.each do |package, version|
366
+ unless deps[package] || installed_package_version(package)
317
367
  @issues << "Missing essential rspack dependency: #{package} (#{version})"
318
368
  end
319
369
  end
370
+
371
+ RSPACK_DEV_SERVER_DEP.each do |package, version|
372
+ unless deps[package] || installed_package_version(package)
373
+ add_warning("Missing recommended rspack dependency: #{package} (#{version}) for Rspack dev server")
374
+ end
375
+ end
376
+
377
+ unsupported_packages = RSPACK_V2_ONLY_DEPS.keys.select do |package|
378
+ deps[package] &&
379
+ (rspack_major_version_for(package) == 1 || rspack_declared_major_version_for(package) == 1)
380
+ end
381
+
382
+ if unsupported_packages.any?
383
+ @issues << "Unsupported rspack dependency version: Shakapacker supports Rspack v2 only. " \
384
+ "Upgrade #{unsupported_packages.join(' and ')} to ^2.0.0."
385
+ end
386
+
387
+ unsupported_optional_packages = OPTIONAL_RSPACK_V2_ONLY_DEPS.keys.select do |package|
388
+ deps[package] &&
389
+ (rspack_major_version_for(package) == 1 || rspack_declared_major_version_for(package) == 1)
390
+ end
391
+
392
+ if unsupported_optional_packages.any?
393
+ add_warning("Unsupported optional rspack dependency version: Shakapacker supports Rspack v2 only. " \
394
+ "Upgrade #{unsupported_optional_packages.join(' and ')} to ^2.0.0.")
395
+ end
396
+
397
+ manifest_status = package_version_status("rspack-manifest-plugin", "5.2.2")
398
+ if deps["rspack-manifest-plugin"] && manifest_status[:installed_below]
399
+ @issues << "Unsupported rspack-manifest-plugin version: Shakapacker requires rspack-manifest-plugin " \
400
+ "^5.2.2 for Rspack v2."
401
+ end
402
+
403
+ if deps["rspack-manifest-plugin"] && manifest_status[:declared_below]
404
+ @issues << "Declared rspack-manifest-plugin range allows unsupported versions. " \
405
+ "Update package.json to require rspack-manifest-plugin ^5.2.2 for Rspack v2."
406
+ end
320
407
  end
321
408
 
322
409
  def check_windows_platform
@@ -423,9 +510,8 @@ module Shakapacker
423
510
  end
424
511
 
425
512
  def check_javascript_transpiler_dependencies
426
- transpiler = config.javascript_transpiler
513
+ transpiler = explicit_javascript_transpiler
427
514
 
428
- # Default to SWC for v9+ if not configured
429
515
  if transpiler.nil?
430
516
  @info << "No javascript_transpiler configured - defaulting to SWC (20x faster than Babel)"
431
517
  transpiler = "swc"
@@ -433,14 +519,31 @@ module Shakapacker
433
519
 
434
520
  return if transpiler == "none"
435
521
 
436
- bundler = config.assets_bundler
522
+ bundler = assets_bundler
523
+ unconfigured_hybrid_graph = unconfigured_hybrid_loader_graph?
524
+ inferred_hybrid_graph = inferred_hybrid_loader_graph?(
525
+ transpiler,
526
+ bundler,
527
+ unconfigured_hybrid_graph: unconfigured_hybrid_graph
528
+ )
529
+ if inferred_hybrid_graph
530
+ add_info_warning("Detected a custom hybrid webpack/Rspack setup while Doctor inferred webpack/SWC. " \
531
+ "Skipping SWC dependency issue checks for this inferred default. For custom hybrid webpack/Rspack configs, " \
532
+ "set javascript_transpiler: \"none\" when Shakapacker should not validate loader dependencies, " \
533
+ "or set javascript_transpiler/assets_bundler explicitly when Shakapacker owns that build path.")
534
+ elsif unconfigured_hybrid_graph
535
+ add_info_warning("Detected a custom hybrid webpack/Rspack setup with inferred Shakapacker defaults. " \
536
+ "Doctor is validating the active #{bundler}/#{transpiler} default only. " \
537
+ "Set javascript_transpiler: \"none\" when Shakapacker should not validate loader dependencies, " \
538
+ "or set javascript_transpiler/assets_bundler explicitly when Shakapacker owns that build path.")
539
+ end
437
540
 
438
541
  case transpiler
439
542
  when "babel"
440
543
  check_babel_dependencies
441
544
  check_babel_performance_suggestion
442
545
  when "swc"
443
- check_swc_dependencies(bundler)
546
+ check_swc_dependencies(bundler) unless inferred_hybrid_graph
444
547
  when "esbuild"
445
548
  check_esbuild_dependencies
446
549
  else
@@ -451,7 +554,7 @@ module Shakapacker
451
554
  end
452
555
  end
453
556
 
454
- check_transpiler_config_consistency
557
+ check_transpiler_config_consistency(transpiler, inferred_hybrid_graph: inferred_hybrid_graph)
455
558
  end
456
559
 
457
560
  def check_babel_dependencies
@@ -505,7 +608,9 @@ module Shakapacker
505
608
  end
506
609
  end
507
610
 
508
- def check_transpiler_config_consistency
611
+ def check_transpiler_config_consistency(transpiler = javascript_transpiler, inferred_hybrid_graph: nil)
612
+ inferred_hybrid_graph = inferred_hybrid_loader_graph?(transpiler, assets_bundler) if inferred_hybrid_graph.nil?
613
+
509
614
  babel_configs = [
510
615
  root_path.join(".babelrc"),
511
616
  root_path.join(".babelrc.js"),
@@ -519,14 +624,11 @@ module Shakapacker
519
624
 
520
625
  # Check if package.json has babel config
521
626
  if package_json_exists?
522
- package_json = read_package_json
523
- babel_in_package_json = package_json.key?("babel")
627
+ babel_in_package_json = package_json_key?("babel")
524
628
  babel_config_exists ||= babel_in_package_json
525
629
  end
526
630
 
527
- transpiler = config.javascript_transpiler
528
-
529
- if babel_config_exists && transpiler != "babel"
631
+ if babel_config_exists && transpiler != "babel" && !inferred_hybrid_graph
530
632
  babel_files = babel_configs.select(&:exist?).map { |f| f.relative_path_from(root_path) }
531
633
  babel_files << "package.json" if babel_in_package_json
532
634
  babel_files_str = babel_files.join(", ")
@@ -535,7 +637,7 @@ module Shakapacker
535
637
  end
536
638
 
537
639
  # Check for redundant dependencies
538
- if transpiler == "swc" && package_installed?("babel-loader")
640
+ if transpiler == "swc" && package_installed?("babel-loader") && !inferred_hybrid_graph
539
641
  add_warning("Both SWC and Babel dependencies are installed. Consider removing Babel dependencies to reduce node_modules size")
540
642
  end
541
643
 
@@ -549,6 +651,49 @@ module Shakapacker
549
651
  end
550
652
  end
551
653
 
654
+ def inferred_hybrid_loader_graph?(transpiler, bundler, unconfigured_hybrid_graph: nil)
655
+ unconfigured_hybrid_graph = unconfigured_hybrid_loader_graph? if unconfigured_hybrid_graph.nil?
656
+
657
+ transpiler == "swc" &&
658
+ bundler == "webpack" &&
659
+ unconfigured_hybrid_graph
660
+ end
661
+
662
+ def unconfigured_hybrid_loader_graph?
663
+ !javascript_transpiler_configured? &&
664
+ !assets_bundler_configured? &&
665
+ package_installed?("webpack") &&
666
+ package_installed?("@rspack/core") &&
667
+ inferred_hybrid_bundler_config_present? &&
668
+ custom_hybrid_loader_dependency?
669
+ end
670
+
671
+ def inferred_hybrid_bundler_config_present?
672
+ same_directory_hybrid_config_present?(config.assets_bundler_config_path.to_s) ||
673
+ default_split_hybrid_config_present?
674
+ end
675
+
676
+ def same_directory_hybrid_config_present?(directory)
677
+ bundler_config_present?(directory, "webpack") &&
678
+ bundler_config_present?(directory, "rspack")
679
+ end
680
+
681
+ def default_split_hybrid_config_present?
682
+ bundler_config_present?("config/webpack", "webpack") &&
683
+ (bundler_config_present?("config/rspack", "rspack") ||
684
+ bundler_config_present?("config/rspack", "webpack"))
685
+ end
686
+
687
+ def bundler_config_present?(directory, basename)
688
+ BUNDLER_CONFIG_EXTENSIONS.any? do |extension|
689
+ Pathname.new(File.join(root_path.to_s, directory.to_s, "#{basename}.config.#{extension}")).exist?
690
+ end
691
+ end
692
+
693
+ def custom_hybrid_loader_dependency?
694
+ CUSTOM_HYBRID_LOADER_DEPS.any? { |package_name| package_installed?(package_name) }
695
+ end
696
+
552
697
  def check_swc_config_conflicts
553
698
  swcrc_path = root_path.join(".swcrc")
554
699
  swc_config_path = root_path.join("config/swc.config.js")
@@ -602,11 +747,9 @@ module Shakapacker
602
747
  def stimulus_likely_used?
603
748
  return false unless package_json_exists?
604
749
 
605
- package_json = read_package_json
606
- dependencies = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
607
-
608
750
  # Check for @hotwired/stimulus or stimulus package
609
- dependencies.key?("@hotwired/stimulus") || dependencies.key?("stimulus")
751
+ declared_package_dependencies.key?("@hotwired/stimulus") ||
752
+ declared_package_dependencies.key?("stimulus")
610
753
  end
611
754
 
612
755
  def check_css_dependencies
@@ -681,7 +824,7 @@ module Shakapacker
681
824
  end
682
825
 
683
826
  def check_bundler_dependencies
684
- bundler = config.assets_bundler
827
+ bundler = assets_bundler
685
828
  case bundler
686
829
  when "webpack"
687
830
  check_dependency("webpack", @issues, "webpack")
@@ -698,9 +841,9 @@ module Shakapacker
698
841
  rspack_major = rspack_major_version
699
842
 
700
843
  if rspack_major == 1
701
- add_warning("Rspack v1 detected: persistent caching is experimental in v1 and requires manual opt-in. " \
702
- "Upgrading to Rspack v2 enables stable persistent caching out of the box for significantly faster rebuilds.")
703
- add_fix_hint("Bump @rspack/core and @rspack/cli to ^2.0.0-0 in package.json. See https://rspack.rs/config/cache and docs/rspack.md for details.")
844
+ add_warning("Rspack v1 detected: Shakapacker supports Rspack v2 only. " \
845
+ "Upgrade to Rspack v2 for supported builds and stable persistent caching.")
846
+ add_fix_hint("Bump @rspack/core and @rspack/cli to ^2.0.0 in package.json. See https://rspack.rs/config/cache and docs/rspack.md for details.")
704
847
  end
705
848
 
706
849
  path = active_assets_bundler_config_path
@@ -943,17 +1086,24 @@ module Shakapacker
943
1086
  end
944
1087
 
945
1088
  def rspack_major_version
946
- # Prefer the resolved version from node_modules, since package.json specifiers
947
- # (ranges, git refs, file paths) often don't parse to a clean major number.
948
- resolved = installed_rspack_major_version
949
- return resolved unless resolved.nil?
950
-
951
- %w[@rspack/core @rspack/cli].each do |package_name|
952
- major = rspack_major_from_specifier(package_json_dependency_version(package_name))
953
- return major unless major.nil?
1089
+ majors = %w[@rspack/core @rspack/cli].filter_map do |package_name|
1090
+ rspack_major_version_for(package_name)
954
1091
  end
955
1092
 
956
- nil
1093
+ return 1 if majors.include?(1)
1094
+
1095
+ majors.first
1096
+ end
1097
+
1098
+ def rspack_major_version_for(package_name)
1099
+ installed = installed_rspack_major_version(package_name)
1100
+ return installed if installed
1101
+
1102
+ rspack_declared_major_version_for(package_name)
1103
+ end
1104
+
1105
+ def rspack_declared_major_version_for(package_name)
1106
+ rspack_major_from_specifier(package_json_dependency_version(package_name))
957
1107
  end
958
1108
 
959
1109
  def rspack_major_from_specifier(version)
@@ -964,16 +1114,23 @@ module Shakapacker
964
1114
  # ">=1.5 <2". Accept shorthand forms (e.g. `^1`, `~1`, `1`, `1.x`) so
965
1115
  # we still emit the v1 advisory when node_modules isn't populated yet.
966
1116
  cleaned = version.strip
1117
+ if cleaned.include?("||")
1118
+ majors = cleaned.split("||").filter_map { |specifier| rspack_major_from_specifier(specifier) }
1119
+ return 1 if majors.include?(1)
1120
+
1121
+ return majors.first
1122
+ end
1123
+
967
1124
  return nil unless cleaned.match?(/\A[\^~]?\d/)
968
- return nil if cleaned.match?(/(\s|\|\||[<>=:]|\A(?:git|file|link|workspace|npm):)/)
1125
+ return nil if cleaned.match?(/(\s|[<>=:]|\A(?:git|file|link|workspace|npm):)/)
969
1126
  return nil unless cleaned.match?(/\A[\^~]?\d+(?:\.(?:\d+|x|\*)){0,2}(?:-[0-9A-Za-z.-]+)?\z/i)
970
1127
 
971
1128
  match = cleaned.sub(/\A[\^~]/, "").match(/\A(\d+)/)
972
1129
  match && match[1].to_i
973
1130
  end
974
1131
 
975
- def installed_rspack_major_version
976
- rspack_pkg = root_path.join("node_modules/@rspack/core/package.json")
1132
+ def installed_rspack_major_version(package_name)
1133
+ rspack_pkg = installed_package_json_path(package_name)
977
1134
  return nil unless rspack_pkg.exist?
978
1135
 
979
1136
  version = JSON.parse(File.read(rspack_pkg))["version"]
@@ -986,11 +1143,66 @@ module Shakapacker
986
1143
  def package_json_dependency_version(name)
987
1144
  return nil unless package_json_exists?
988
1145
 
989
- pkg = read_package_json
990
- # Production dependencies take precedence on key conflict so the version
991
- # actually shipped in production wins over a devDependencies override.
992
- deps = (pkg["devDependencies"] || {}).merge(pkg["dependencies"] || {})
993
- deps[name]
1146
+ declared_package_dependencies[name]
1147
+ end
1148
+
1149
+ def package_version_status(package_name, minimum_version)
1150
+ minimum = Gem::Version.new(minimum_version)
1151
+ declared_specifier = package_json_dependency_version(package_name)
1152
+ declared = package_version_from_specifier(declared_specifier)
1153
+ installed = installed_package_version(package_name)
1154
+
1155
+ {
1156
+ declared_below: declared && declared < minimum,
1157
+ installed_below: installed && installed < minimum
1158
+ }
1159
+ end
1160
+
1161
+ def package_version_from_specifier(version)
1162
+ return nil unless version
1163
+
1164
+ cleaned = version.strip
1165
+ if cleaned.include?("||")
1166
+ versions = cleaned.split("||").filter_map { |specifier| package_version_from_specifier(specifier) }
1167
+ return versions.min
1168
+ end
1169
+
1170
+ return nil unless cleaned.match?(/\A[\^~]?\d/)
1171
+ return nil if cleaned.match?(/(\s|\|\||[<>=:]|\A(?:git|file|link|workspace|npm):)/)
1172
+
1173
+ match = cleaned.sub(/\A[\^~]/, "").match(/\A(\d+(?:\.\d+){0,2})(?:-[0-9A-Za-z.-]+)?\z/)
1174
+ match && Gem::Version.new(match[1])
1175
+ rescue ArgumentError
1176
+ nil
1177
+ end
1178
+
1179
+ def declared_package_dependencies
1180
+ @declared_package_dependencies ||= begin
1181
+ package_json_paths.reverse_each.each_with_object({}) do |path, dependencies|
1182
+ package_json = parse_package_json(path)
1183
+ next unless package_json
1184
+
1185
+ dependencies.merge!(installable_package_dependencies(package_json))
1186
+ end
1187
+ end
1188
+ end
1189
+
1190
+ def installable_package_dependencies(package_json)
1191
+ # Later sections take precedence when the same package is declared in more than one section.
1192
+ (package_json["optionalDependencies"] || {})
1193
+ .merge(package_json["devDependencies"] || {})
1194
+ .merge(package_json["dependencies"] || {})
1195
+ end
1196
+
1197
+ def installed_package_version(package_name)
1198
+ package_json = installed_package_json_path(package_name)
1199
+ return nil unless package_json.exist?
1200
+
1201
+ version = JSON.parse(File.read(package_json))["version"]
1202
+ match = version.to_s.match(/\A(\d+(?:\.\d+){0,2})/)
1203
+ match && Gem::Version.new(match[1])
1204
+ rescue JSON::ParserError, SystemCallError, ArgumentError
1205
+ nil
994
1206
  end
995
1207
 
996
1208
  def check_file_type_dependencies
@@ -1005,7 +1217,7 @@ module Shakapacker
1005
1217
  end
1006
1218
 
1007
1219
  def check_typescript_dependencies
1008
- transpiler = config.javascript_transpiler
1220
+ transpiler = javascript_transpiler
1009
1221
  if transpiler == "babel"
1010
1222
  check_optional_dependency("@babel/preset-typescript", @warnings, "TypeScript with Babel")
1011
1223
  elsif transpiler != "esbuild" && transpiler != "swc"
@@ -1015,7 +1227,9 @@ module Shakapacker
1015
1227
 
1016
1228
  def check_sass_dependencies
1017
1229
  check_dependency("sass-loader", @issues, "Sass/SCSS")
1018
- check_dependency("sass", @issues, "Sass/SCSS (sass package)")
1230
+ unless SASS_IMPLEMENTATION_PACKAGES.any? { |package_name| package_installed?(package_name) }
1231
+ @issues << SASS_IMPLEMENTATION_DEPENDENCY_MESSAGE
1232
+ end
1019
1233
  end
1020
1234
 
1021
1235
  def check_less_dependencies
@@ -1048,27 +1262,170 @@ module Shakapacker
1048
1262
  def package_installed?(package_name)
1049
1263
  return false unless package_json_exists?
1050
1264
 
1051
- package_json = read_package_json
1052
- dependencies = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
1053
- dependencies.key?(package_name)
1265
+ declared_package_dependencies.key?(package_name)
1054
1266
  end
1055
1267
 
1056
1268
  def package_json_exists?
1057
- package_json_path.exist?
1269
+ package_json_paths.any?
1270
+ end
1271
+
1272
+ def javascript_transpiler_configured?
1273
+ !javascript_transpiler_env_override.nil? ||
1274
+ config_key_defined?(:javascript_transpiler) ||
1275
+ config_key_defined?(:webpack_loader)
1058
1276
  end
1059
1277
 
1060
- def package_json_path
1061
- root_path.join("package.json")
1278
+ def javascript_transpiler
1279
+ transpiler = javascript_transpiler_env_override || config.javascript_transpiler
1280
+ blank_config_value?(transpiler) ? default_javascript_transpiler : transpiler
1062
1281
  end
1063
1282
 
1064
- def read_package_json
1065
- @package_json ||= begin
1066
- JSON.parse(File.read(package_json_path))
1067
- rescue JSON::ParserError
1068
- {}
1283
+ def explicit_javascript_transpiler
1284
+ return javascript_transpiler_env_override if javascript_transpiler_env_override
1285
+ return nil unless javascript_transpiler_configured?
1286
+
1287
+ javascript_transpiler
1288
+ end
1289
+
1290
+ def javascript_transpiler_env_override
1291
+ value = ENV["SHAKAPACKER_JAVASCRIPT_TRANSPILER"]
1292
+ return nil if value.nil? || value.empty?
1293
+
1294
+ value
1295
+ end
1296
+
1297
+ def assets_bundler_configured?
1298
+ assets_bundler_override_configured? ||
1299
+ ENV.key?("SHAKAPACKER_ASSETS_BUNDLER") ||
1300
+ !assets_bundler_env_override.nil? ||
1301
+ config_key_present?(:assets_bundler) ||
1302
+ config_key_present?(:bundler)
1303
+ end
1304
+
1305
+ def assets_bundler
1306
+ config.assets_bundler
1307
+ end
1308
+
1309
+ def assets_bundler_env_override
1310
+ value = ENV["SHAKAPACKER_ASSETS_BUNDLER"]
1311
+ return nil if value.nil? || value.empty?
1312
+
1313
+ value
1314
+ end
1315
+
1316
+ def assets_bundler_override_configured?
1317
+ config.respond_to?(:bundler_override) && !blank_config_value?(config.bundler_override)
1318
+ end
1319
+
1320
+ def empty_assets_bundler_env_override?
1321
+ ENV.key?("SHAKAPACKER_ASSETS_BUNDLER") && ENV["SHAKAPACKER_ASSETS_BUNDLER"].empty?
1322
+ end
1323
+
1324
+ def report_empty_assets_bundler_env_override
1325
+ return if @empty_assets_bundler_env_override_reported
1326
+
1327
+ @issues << "SHAKAPACKER_ASSETS_BUNDLER is set but empty. Unset it, or set it to 'webpack' or 'rspack'."
1328
+ @empty_assets_bundler_env_override_reported = true
1329
+ end
1330
+
1331
+ def blank_config_value?(value)
1332
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
1333
+ end
1334
+
1335
+ def default_javascript_transpiler
1336
+ assets_bundler == "rspack" ? "swc" : "babel"
1337
+ end
1338
+
1339
+ def config_key_present?(key)
1340
+ !blank_config_value?(config_value(key))
1341
+ end
1342
+
1343
+ def config_key_defined?(key)
1344
+ return false unless config.respond_to?(:data)
1345
+
1346
+ data = config.data
1347
+ data.respond_to?(:key?) && data.key?(key)
1348
+ end
1349
+
1350
+ def config_value(key)
1351
+ return nil unless config.respond_to?(:data)
1352
+
1353
+ data = config.data
1354
+ return nil unless data.respond_to?(:key?) && data.key?(key)
1355
+
1356
+ data[key]
1357
+ end
1358
+
1359
+ def parse_package_json(path)
1360
+ JSON.parse(File.read(path))
1361
+ rescue JSON::ParserError, SystemCallError
1362
+ nil
1363
+ end
1364
+
1365
+ def package_json_key?(key)
1366
+ package_json_paths.any? do |path|
1367
+ package_json = parse_package_json(path)
1368
+ package_json.is_a?(Hash) && package_json.key?(key)
1369
+ end
1370
+ end
1371
+
1372
+ def package_json_paths
1373
+ @package_json_paths ||= package_root_paths
1374
+ .map { |path| path.join("package.json") }
1375
+ .select(&:exist?)
1376
+ end
1377
+
1378
+ def javascript_package_root_path
1379
+ @javascript_package_root_path ||= begin
1380
+ source_path = config.source_path.expand_path
1381
+ app_root = root_path.expand_path
1382
+
1383
+ if path_within?(source_path, app_root)
1384
+ current = source_path
1385
+ loop do
1386
+ break current if package_root_marker?(current)
1387
+ break root_path if current == app_root
1388
+
1389
+ parent = current.dirname
1390
+ break root_path if parent == current
1391
+
1392
+ current = parent
1393
+ end
1394
+ else
1395
+ root_path
1396
+ end
1397
+ rescue StandardError
1398
+ root_path
1069
1399
  end
1070
1400
  end
1071
1401
 
1402
+ def node_modules_path
1403
+ node_modules_paths.first
1404
+ end
1405
+
1406
+ def node_modules_paths
1407
+ @node_modules_paths ||= package_root_paths.map { |path| path.join("node_modules") }
1408
+ end
1409
+
1410
+ def installed_package_json_path(package_name)
1411
+ node_modules_paths
1412
+ .map { |path| path.join(package_name, "package.json") }
1413
+ .find(&:exist?) || node_modules_path.join(package_name, "package.json")
1414
+ end
1415
+
1416
+ def package_root_paths
1417
+ @package_root_paths ||= [javascript_package_root_path, root_path].uniq
1418
+ end
1419
+
1420
+ def package_root_marker?(path)
1421
+ # Keep aligned with shakapacker_package_root_marker? in the helper binstubs.
1422
+ PACKAGE_ROOT_MARKERS.any? { |entry| path.join(entry).exist? }
1423
+ end
1424
+
1425
+ def path_within?(path, parent)
1426
+ path.to_s == parent.to_s || path.to_s.start_with?("#{parent}#{File::SEPARATOR}")
1427
+ end
1428
+
1072
1429
  def config_exists?
1073
1430
  config.config_path.exist?
1074
1431
  end
@@ -1099,10 +1456,25 @@ module Shakapacker
1099
1456
  end
1100
1457
 
1101
1458
  def detect_package_manager
1102
- return "bun" if File.exist?(root_path.join("bun.lockb"))
1103
- return "pnpm" if File.exist?(root_path.join("pnpm-lock.yaml"))
1104
- return "yarn" if File.exist?(root_path.join("yarn.lock"))
1105
- return "npm" if File.exist?(root_path.join("package-lock.json"))
1459
+ root_package_manager = package_manager_for(root_path)
1460
+
1461
+ package_root_paths.each do |package_root|
1462
+ next if package_root == root_path
1463
+
1464
+ package_manager_name = package_manager_for(package_root)
1465
+ next unless package_manager_name
1466
+
1467
+ return package_manager_name if package_root.join("package.json").exist? || root_package_manager.nil?
1468
+ end
1469
+
1470
+ root_package_manager
1471
+ end
1472
+
1473
+ def package_manager_for(package_root)
1474
+ PACKAGE_MANAGER_LOCKFILES.each do |lockfile, package_manager_name|
1475
+ return package_manager_name if File.exist?(package_root.join(lockfile))
1476
+ end
1477
+
1106
1478
  nil
1107
1479
  end
1108
1480
 
@@ -1176,7 +1548,7 @@ module Shakapacker
1176
1548
  config_relative_path = doctor.config.config_path.relative_path_from(doctor.root_path)
1177
1549
  puts "✓ Configuration file found (#{config_relative_path})"
1178
1550
  if verbose?
1179
- puts " Assets bundler: #{doctor.config.assets_bundler}"
1551
+ puts " Assets bundler: #{doctor.send(:assets_bundler)}"
1180
1552
  puts " Source path: #{doctor.config.source_path.relative_path_from(doctor.root_path)}"
1181
1553
  puts " Public output path: #{doctor.config.public_output_path.relative_path_from(doctor.root_path)}"
1182
1554
  end
@@ -1212,9 +1584,7 @@ module Shakapacker
1212
1584
  def print_version_info
1213
1585
  return unless doctor.send(:package_json_exists?)
1214
1586
 
1215
- package_json = doctor.send(:read_package_json)
1216
- npm_version = package_json.dig("dependencies", "shakapacker") ||
1217
- package_json.dig("devDependencies", "shakapacker")
1587
+ npm_version = doctor.send(:package_json_dependency_version, "shakapacker")
1218
1588
  puts " • Shakapacker gem version: #{Shakapacker::VERSION}"
1219
1589
  puts " • Shakapacker npm version: #{npm_version || 'not installed'}"
1220
1590
  end
@@ -1275,7 +1645,7 @@ module Shakapacker
1275
1645
  end
1276
1646
 
1277
1647
  def print_transpiler_status
1278
- transpiler = doctor.config.javascript_transpiler
1648
+ transpiler = doctor.send(:javascript_transpiler)
1279
1649
  return if transpiler.nil? || transpiler == "none"
1280
1650
 
1281
1651
  loader_name = "#{transpiler}-loader"
@@ -1285,7 +1655,7 @@ module Shakapacker
1285
1655
  end
1286
1656
 
1287
1657
  def print_bundler_status
1288
- bundler = doctor.config.assets_bundler
1658
+ bundler = doctor.send(:assets_bundler)
1289
1659
  case bundler
1290
1660
  when "webpack"
1291
1661
  print_package_status("webpack", "webpack")