shakapacker 10.1.0 → 10.3.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,24 +510,42 @@ module Shakapacker
423
510
  end
424
511
 
425
512
  def check_javascript_transpiler_dependencies
426
- transpiler = config.javascript_transpiler
513
+ transpiler = explicit_javascript_transpiler
514
+ implicit_babel_fallback = false
427
515
 
428
- # Default to SWC for v9+ if not configured
429
516
  if transpiler.nil?
430
- @info << "No javascript_transpiler configured - defaulting to SWC (20x faster than Babel)"
431
- transpiler = "swc"
517
+ transpiler = javascript_transpiler
518
+ implicit_babel_fallback = transpiler == "babel"
432
519
  end
433
520
 
521
+ @resolved_javascript_transpiler = transpiler
434
522
  return if transpiler == "none"
435
523
 
436
- bundler = config.assets_bundler
524
+ bundler = assets_bundler
525
+ unconfigured_hybrid_graph = unconfigured_hybrid_loader_graph?
526
+ inferred_hybrid_graph = inferred_hybrid_loader_graph?(
527
+ transpiler,
528
+ bundler,
529
+ unconfigured_hybrid_graph: unconfigured_hybrid_graph
530
+ )
531
+ if inferred_hybrid_graph
532
+ add_info_warning("Detected a custom hybrid webpack/Rspack setup while Doctor inferred webpack/SWC. " \
533
+ "Skipping SWC dependency issue checks for this inferred default. For custom hybrid webpack/Rspack configs, " \
534
+ "set javascript_transpiler: \"none\" when Shakapacker should not validate loader dependencies, " \
535
+ "or set javascript_transpiler/assets_bundler explicitly when Shakapacker owns that build path.")
536
+ elsif unconfigured_hybrid_graph
537
+ add_info_warning("Detected a custom hybrid webpack/Rspack setup with inferred Shakapacker defaults. " \
538
+ "Doctor is validating the active #{bundler}/#{transpiler} default only. " \
539
+ "Set javascript_transpiler: \"none\" when Shakapacker should not validate loader dependencies, " \
540
+ "or set javascript_transpiler/assets_bundler explicitly when Shakapacker owns that build path.")
541
+ end
437
542
 
438
543
  case transpiler
439
544
  when "babel"
440
545
  check_babel_dependencies
441
- check_babel_performance_suggestion
546
+ check_babel_performance_suggestion unless implicit_babel_fallback
442
547
  when "swc"
443
- check_swc_dependencies(bundler)
548
+ check_swc_dependencies(bundler) unless inferred_hybrid_graph
444
549
  when "esbuild"
445
550
  check_esbuild_dependencies
446
551
  else
@@ -451,7 +556,7 @@ module Shakapacker
451
556
  end
452
557
  end
453
558
 
454
- check_transpiler_config_consistency
559
+ check_transpiler_config_consistency(transpiler, inferred_hybrid_graph: inferred_hybrid_graph)
455
560
  end
456
561
 
457
562
  def check_babel_dependencies
@@ -470,6 +575,20 @@ module Shakapacker
470
575
  @info << "Consider switching to SWC for 20x faster compilation. Set javascript_transpiler: 'swc' in shakapacker.yml"
471
576
  end
472
577
 
578
+ def implicit_javascript_transpiler
579
+ if assets_bundler == "webpack" && !package_installed?("swc-loader") && package_installed?("babel-loader")
580
+ @info << "`javascript_transpiler` is not set in config/shakapacker.yml. " \
581
+ "Shakapacker defaults to SWC, but swc-loader is not installed and Babel was detected, so Babel will be used. " \
582
+ "Set `javascript_transpiler: babel` (or `swc`) explicitly to silence this message. " \
583
+ "See https://github.com/shakacode/shakapacker/blob/main/docs/transpiler-migration.md"
584
+ "babel"
585
+ else
586
+ @info << "No javascript_transpiler configured - using bundled SWC default. " \
587
+ "Set javascript_transpiler: 'swc' or 'babel' explicitly in shakapacker.yml to silence this message."
588
+ "swc"
589
+ end
590
+ end
591
+
473
592
  def check_swc_dependencies(bundler)
474
593
  if bundler == "webpack"
475
594
  unless package_installed?("@swc/core")
@@ -505,7 +624,9 @@ module Shakapacker
505
624
  end
506
625
  end
507
626
 
508
- def check_transpiler_config_consistency
627
+ def check_transpiler_config_consistency(transpiler = javascript_transpiler, inferred_hybrid_graph: nil)
628
+ inferred_hybrid_graph = inferred_hybrid_loader_graph?(transpiler, assets_bundler) if inferred_hybrid_graph.nil?
629
+
509
630
  babel_configs = [
510
631
  root_path.join(".babelrc"),
511
632
  root_path.join(".babelrc.js"),
@@ -519,14 +640,11 @@ module Shakapacker
519
640
 
520
641
  # Check if package.json has babel config
521
642
  if package_json_exists?
522
- package_json = read_package_json
523
- babel_in_package_json = package_json.key?("babel")
643
+ babel_in_package_json = package_json_key?("babel")
524
644
  babel_config_exists ||= babel_in_package_json
525
645
  end
526
646
 
527
- transpiler = config.javascript_transpiler
528
-
529
- if babel_config_exists && transpiler != "babel"
647
+ if babel_config_exists && transpiler != "babel" && !inferred_hybrid_graph
530
648
  babel_files = babel_configs.select(&:exist?).map { |f| f.relative_path_from(root_path) }
531
649
  babel_files << "package.json" if babel_in_package_json
532
650
  babel_files_str = babel_files.join(", ")
@@ -535,7 +653,7 @@ module Shakapacker
535
653
  end
536
654
 
537
655
  # Check for redundant dependencies
538
- if transpiler == "swc" && package_installed?("babel-loader")
656
+ if transpiler == "swc" && package_installed?("babel-loader") && !inferred_hybrid_graph
539
657
  add_warning("Both SWC and Babel dependencies are installed. Consider removing Babel dependencies to reduce node_modules size")
540
658
  end
541
659
 
@@ -549,6 +667,49 @@ module Shakapacker
549
667
  end
550
668
  end
551
669
 
670
+ def inferred_hybrid_loader_graph?(transpiler, bundler, unconfigured_hybrid_graph: nil)
671
+ unconfigured_hybrid_graph = unconfigured_hybrid_loader_graph? if unconfigured_hybrid_graph.nil?
672
+
673
+ transpiler == "swc" &&
674
+ bundler == "webpack" &&
675
+ unconfigured_hybrid_graph
676
+ end
677
+
678
+ def unconfigured_hybrid_loader_graph?
679
+ !javascript_transpiler_configured? &&
680
+ !assets_bundler_configured? &&
681
+ package_installed?("webpack") &&
682
+ package_installed?("@rspack/core") &&
683
+ inferred_hybrid_bundler_config_present? &&
684
+ custom_hybrid_loader_dependency?
685
+ end
686
+
687
+ def inferred_hybrid_bundler_config_present?
688
+ same_directory_hybrid_config_present?(config.assets_bundler_config_path.to_s) ||
689
+ default_split_hybrid_config_present?
690
+ end
691
+
692
+ def same_directory_hybrid_config_present?(directory)
693
+ bundler_config_present?(directory, "webpack") &&
694
+ bundler_config_present?(directory, "rspack")
695
+ end
696
+
697
+ def default_split_hybrid_config_present?
698
+ bundler_config_present?("config/webpack", "webpack") &&
699
+ (bundler_config_present?("config/rspack", "rspack") ||
700
+ bundler_config_present?("config/rspack", "webpack"))
701
+ end
702
+
703
+ def bundler_config_present?(directory, basename)
704
+ BUNDLER_CONFIG_EXTENSIONS.any? do |extension|
705
+ Pathname.new(File.join(root_path.to_s, directory.to_s, "#{basename}.config.#{extension}")).exist?
706
+ end
707
+ end
708
+
709
+ def custom_hybrid_loader_dependency?
710
+ CUSTOM_HYBRID_LOADER_DEPS.any? { |package_name| package_installed?(package_name) }
711
+ end
712
+
552
713
  def check_swc_config_conflicts
553
714
  swcrc_path = root_path.join(".swcrc")
554
715
  swc_config_path = root_path.join("config/swc.config.js")
@@ -602,11 +763,9 @@ module Shakapacker
602
763
  def stimulus_likely_used?
603
764
  return false unless package_json_exists?
604
765
 
605
- package_json = read_package_json
606
- dependencies = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
607
-
608
766
  # Check for @hotwired/stimulus or stimulus package
609
- dependencies.key?("@hotwired/stimulus") || dependencies.key?("stimulus")
767
+ declared_package_dependencies.key?("@hotwired/stimulus") ||
768
+ declared_package_dependencies.key?("stimulus")
610
769
  end
611
770
 
612
771
  def check_css_dependencies
@@ -681,7 +840,7 @@ module Shakapacker
681
840
  end
682
841
 
683
842
  def check_bundler_dependencies
684
- bundler = config.assets_bundler
843
+ bundler = assets_bundler
685
844
  case bundler
686
845
  when "webpack"
687
846
  check_dependency("webpack", @issues, "webpack")
@@ -698,9 +857,9 @@ module Shakapacker
698
857
  rspack_major = rspack_major_version
699
858
 
700
859
  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.")
860
+ add_warning("Rspack v1 detected: Shakapacker supports Rspack v2 only. " \
861
+ "Upgrade to Rspack v2 for supported builds and stable persistent caching.")
862
+ 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
863
  end
705
864
 
706
865
  path = active_assets_bundler_config_path
@@ -943,17 +1102,24 @@ module Shakapacker
943
1102
  end
944
1103
 
945
1104
  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?
1105
+ majors = %w[@rspack/core @rspack/cli].filter_map do |package_name|
1106
+ rspack_major_version_for(package_name)
954
1107
  end
955
1108
 
956
- nil
1109
+ return 1 if majors.include?(1)
1110
+
1111
+ majors.first
1112
+ end
1113
+
1114
+ def rspack_major_version_for(package_name)
1115
+ installed = installed_rspack_major_version(package_name)
1116
+ return installed if installed
1117
+
1118
+ rspack_declared_major_version_for(package_name)
1119
+ end
1120
+
1121
+ def rspack_declared_major_version_for(package_name)
1122
+ rspack_major_from_specifier(package_json_dependency_version(package_name))
957
1123
  end
958
1124
 
959
1125
  def rspack_major_from_specifier(version)
@@ -964,16 +1130,23 @@ module Shakapacker
964
1130
  # ">=1.5 <2". Accept shorthand forms (e.g. `^1`, `~1`, `1`, `1.x`) so
965
1131
  # we still emit the v1 advisory when node_modules isn't populated yet.
966
1132
  cleaned = version.strip
1133
+ if cleaned.include?("||")
1134
+ majors = cleaned.split("||").filter_map { |specifier| rspack_major_from_specifier(specifier) }
1135
+ return 1 if majors.include?(1)
1136
+
1137
+ return majors.first
1138
+ end
1139
+
967
1140
  return nil unless cleaned.match?(/\A[\^~]?\d/)
968
- return nil if cleaned.match?(/(\s|\|\||[<>=:]|\A(?:git|file|link|workspace|npm):)/)
1141
+ return nil if cleaned.match?(/(\s|[<>=:]|\A(?:git|file|link|workspace|npm):)/)
969
1142
  return nil unless cleaned.match?(/\A[\^~]?\d+(?:\.(?:\d+|x|\*)){0,2}(?:-[0-9A-Za-z.-]+)?\z/i)
970
1143
 
971
1144
  match = cleaned.sub(/\A[\^~]/, "").match(/\A(\d+)/)
972
1145
  match && match[1].to_i
973
1146
  end
974
1147
 
975
- def installed_rspack_major_version
976
- rspack_pkg = root_path.join("node_modules/@rspack/core/package.json")
1148
+ def installed_rspack_major_version(package_name)
1149
+ rspack_pkg = installed_package_json_path(package_name)
977
1150
  return nil unless rspack_pkg.exist?
978
1151
 
979
1152
  version = JSON.parse(File.read(rspack_pkg))["version"]
@@ -986,11 +1159,66 @@ module Shakapacker
986
1159
  def package_json_dependency_version(name)
987
1160
  return nil unless package_json_exists?
988
1161
 
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]
1162
+ declared_package_dependencies[name]
1163
+ end
1164
+
1165
+ def package_version_status(package_name, minimum_version)
1166
+ minimum = Gem::Version.new(minimum_version)
1167
+ declared_specifier = package_json_dependency_version(package_name)
1168
+ declared = package_version_from_specifier(declared_specifier)
1169
+ installed = installed_package_version(package_name)
1170
+
1171
+ {
1172
+ declared_below: declared && declared < minimum,
1173
+ installed_below: installed && installed < minimum
1174
+ }
1175
+ end
1176
+
1177
+ def package_version_from_specifier(version)
1178
+ return nil unless version
1179
+
1180
+ cleaned = version.strip
1181
+ if cleaned.include?("||")
1182
+ versions = cleaned.split("||").filter_map { |specifier| package_version_from_specifier(specifier) }
1183
+ return versions.min
1184
+ end
1185
+
1186
+ return nil unless cleaned.match?(/\A[\^~]?\d/)
1187
+ return nil if cleaned.match?(/(\s|\|\||[<>=:]|\A(?:git|file|link|workspace|npm):)/)
1188
+
1189
+ match = cleaned.sub(/\A[\^~]/, "").match(/\A(\d+(?:\.\d+){0,2})(?:-[0-9A-Za-z.-]+)?\z/)
1190
+ match && Gem::Version.new(match[1])
1191
+ rescue ArgumentError
1192
+ nil
1193
+ end
1194
+
1195
+ def declared_package_dependencies
1196
+ @declared_package_dependencies ||= begin
1197
+ package_json_paths.reverse_each.each_with_object({}) do |path, dependencies|
1198
+ package_json = parse_package_json(path)
1199
+ next unless package_json
1200
+
1201
+ dependencies.merge!(installable_package_dependencies(package_json))
1202
+ end
1203
+ end
1204
+ end
1205
+
1206
+ def installable_package_dependencies(package_json)
1207
+ # Later sections take precedence when the same package is declared in more than one section.
1208
+ (package_json["optionalDependencies"] || {})
1209
+ .merge(package_json["devDependencies"] || {})
1210
+ .merge(package_json["dependencies"] || {})
1211
+ end
1212
+
1213
+ def installed_package_version(package_name)
1214
+ package_json = installed_package_json_path(package_name)
1215
+ return nil unless package_json.exist?
1216
+
1217
+ version = JSON.parse(File.read(package_json))["version"]
1218
+ match = version.to_s.match(/\A(\d+(?:\.\d+){0,2})/)
1219
+ match && Gem::Version.new(match[1])
1220
+ rescue JSON::ParserError, SystemCallError, ArgumentError
1221
+ nil
994
1222
  end
995
1223
 
996
1224
  def check_file_type_dependencies
@@ -1005,7 +1233,7 @@ module Shakapacker
1005
1233
  end
1006
1234
 
1007
1235
  def check_typescript_dependencies
1008
- transpiler = config.javascript_transpiler
1236
+ transpiler = javascript_transpiler
1009
1237
  if transpiler == "babel"
1010
1238
  check_optional_dependency("@babel/preset-typescript", @warnings, "TypeScript with Babel")
1011
1239
  elsif transpiler != "esbuild" && transpiler != "swc"
@@ -1015,7 +1243,9 @@ module Shakapacker
1015
1243
 
1016
1244
  def check_sass_dependencies
1017
1245
  check_dependency("sass-loader", @issues, "Sass/SCSS")
1018
- check_dependency("sass", @issues, "Sass/SCSS (sass package)")
1246
+ unless SASS_IMPLEMENTATION_PACKAGES.any? { |package_name| package_installed?(package_name) }
1247
+ @issues << SASS_IMPLEMENTATION_DEPENDENCY_MESSAGE
1248
+ end
1019
1249
  end
1020
1250
 
1021
1251
  def check_less_dependencies
@@ -1048,27 +1278,178 @@ module Shakapacker
1048
1278
  def package_installed?(package_name)
1049
1279
  return false unless package_json_exists?
1050
1280
 
1051
- package_json = read_package_json
1052
- dependencies = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
1053
- dependencies.key?(package_name)
1281
+ declared_package_dependencies.key?(package_name)
1054
1282
  end
1055
1283
 
1056
1284
  def package_json_exists?
1057
- package_json_path.exist?
1285
+ package_json_paths.any?
1286
+ end
1287
+
1288
+ def javascript_transpiler_configured?
1289
+ !javascript_transpiler_env_override.nil? ||
1290
+ config_key_configured?(:javascript_transpiler) ||
1291
+ config_key_present?(:webpack_loader)
1292
+ end
1293
+
1294
+ def javascript_transpiler
1295
+ return @resolved_javascript_transpiler if defined?(@resolved_javascript_transpiler)
1296
+
1297
+ @resolved_javascript_transpiler =
1298
+ if javascript_transpiler_configured?
1299
+ transpiler = javascript_transpiler_env_override || config.javascript_transpiler
1300
+ blank_config_value?(transpiler) ? default_javascript_transpiler : transpiler
1301
+ else
1302
+ implicit_javascript_transpiler
1303
+ end
1304
+ end
1305
+
1306
+ def explicit_javascript_transpiler
1307
+ return javascript_transpiler_env_override if javascript_transpiler_env_override
1308
+ return nil unless javascript_transpiler_configured?
1309
+
1310
+ javascript_transpiler
1311
+ end
1312
+
1313
+ def javascript_transpiler_env_override
1314
+ value = ENV["SHAKAPACKER_JAVASCRIPT_TRANSPILER"]
1315
+ return nil if value.nil? || value.empty?
1316
+
1317
+ value
1318
+ end
1319
+
1320
+ def assets_bundler_configured?
1321
+ assets_bundler_override_configured? ||
1322
+ ENV.key?("SHAKAPACKER_ASSETS_BUNDLER") ||
1323
+ !assets_bundler_env_override.nil? ||
1324
+ config_key_present?(:assets_bundler) ||
1325
+ config_key_present?(:bundler)
1326
+ end
1327
+
1328
+ def assets_bundler
1329
+ config.assets_bundler
1330
+ end
1331
+
1332
+ def assets_bundler_env_override
1333
+ value = ENV["SHAKAPACKER_ASSETS_BUNDLER"]
1334
+ return nil if value.nil? || value.empty?
1335
+
1336
+ value
1337
+ end
1338
+
1339
+ def assets_bundler_override_configured?
1340
+ config.respond_to?(:bundler_override) && !blank_config_value?(config.bundler_override)
1341
+ end
1342
+
1343
+ def empty_assets_bundler_env_override?
1344
+ ENV.key?("SHAKAPACKER_ASSETS_BUNDLER") && ENV["SHAKAPACKER_ASSETS_BUNDLER"].empty?
1345
+ end
1346
+
1347
+ def report_empty_assets_bundler_env_override
1348
+ return if @empty_assets_bundler_env_override_reported
1349
+
1350
+ @issues << "SHAKAPACKER_ASSETS_BUNDLER is set but empty. Unset it, or set it to 'webpack' or 'rspack'."
1351
+ @empty_assets_bundler_env_override_reported = true
1352
+ end
1353
+
1354
+ def blank_config_value?(value)
1355
+ value.nil? || (value.is_a?(String) && value.strip.empty?) || (value.respond_to?(:empty?) && value.empty?)
1356
+ end
1357
+
1358
+ def default_javascript_transpiler
1359
+ assets_bundler == "rspack" ? "swc" : "babel"
1360
+ end
1361
+
1362
+ def config_key_present?(key)
1363
+ value = config_value(key)
1364
+ value.is_a?(String) && !value.strip.empty?
1365
+ end
1366
+
1367
+ def config_key_configured?(key)
1368
+ return false unless config.respond_to?(:data)
1369
+
1370
+ data = config.data
1371
+ data.respond_to?(:key?) && data.key?(key) && !blank_config_value?(data[key])
1372
+ end
1373
+
1374
+ def config_value(key)
1375
+ return nil unless config.respond_to?(:data)
1376
+
1377
+ data = config.data
1378
+ return nil unless data.respond_to?(:key?) && data.key?(key)
1379
+
1380
+ data[key]
1058
1381
  end
1059
1382
 
1060
- def package_json_path
1061
- root_path.join("package.json")
1383
+ def parse_package_json(path)
1384
+ JSON.parse(File.read(path))
1385
+ rescue JSON::ParserError, SystemCallError
1386
+ nil
1387
+ end
1388
+
1389
+ def package_json_key?(key)
1390
+ package_json_paths.any? do |path|
1391
+ package_json = parse_package_json(path)
1392
+ package_json.is_a?(Hash) && package_json.key?(key)
1393
+ end
1394
+ end
1395
+
1396
+ def package_json_paths
1397
+ @package_json_paths ||= package_root_paths
1398
+ .map { |path| path.join("package.json") }
1399
+ .select(&:exist?)
1062
1400
  end
1063
1401
 
1064
- def read_package_json
1065
- @package_json ||= begin
1066
- JSON.parse(File.read(package_json_path))
1067
- rescue JSON::ParserError
1068
- {}
1402
+ def javascript_package_root_path
1403
+ @javascript_package_root_path ||= begin
1404
+ source_path = config.source_path.expand_path
1405
+ app_root = root_path.expand_path
1406
+
1407
+ if path_within?(source_path, app_root)
1408
+ current = source_path
1409
+ loop do
1410
+ break current if current.join("package.json").exist?
1411
+ break root_path if current == app_root
1412
+
1413
+ parent = current.dirname
1414
+ break root_path if parent == current
1415
+
1416
+ current = parent
1417
+ end
1418
+ else
1419
+ root_path
1420
+ end
1421
+ rescue StandardError
1422
+ root_path
1069
1423
  end
1070
1424
  end
1071
1425
 
1426
+ def node_modules_path
1427
+ node_modules_paths.first
1428
+ end
1429
+
1430
+ def node_modules_paths
1431
+ @node_modules_paths ||= package_root_paths.map { |path| path.join("node_modules") }
1432
+ end
1433
+
1434
+ def installed_package_json_path(package_name)
1435
+ node_modules_paths
1436
+ .map { |path| path.join(package_name, "package.json") }
1437
+ .find(&:exist?) || node_modules_path.join(package_name, "package.json")
1438
+ end
1439
+
1440
+ def package_root_paths
1441
+ @package_root_paths ||= [javascript_package_root_path, root_path].uniq
1442
+ end
1443
+
1444
+ def package_root_marker?(path)
1445
+ # Keep aligned with shakapacker_package_root_marker? in the helper binstubs.
1446
+ PACKAGE_ROOT_MARKERS.any? { |entry| path.join(entry).exist? }
1447
+ end
1448
+
1449
+ def path_within?(path, parent)
1450
+ path.to_s == parent.to_s || path.to_s.start_with?("#{parent}#{File::SEPARATOR}")
1451
+ end
1452
+
1072
1453
  def config_exists?
1073
1454
  config.config_path.exist?
1074
1455
  end
@@ -1099,13 +1480,56 @@ module Shakapacker
1099
1480
  end
1100
1481
 
1101
1482
  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"))
1483
+ root_package_manager = package_manager_for(root_path)
1484
+
1485
+ package_manager_root_paths.each do |package_root|
1486
+ next if package_root == root_path
1487
+
1488
+ package_manager_name = package_manager_for(package_root)
1489
+ next unless package_manager_name
1490
+
1491
+ return package_manager_name if package_root.join("package.json").exist? || root_package_manager.nil?
1492
+ end
1493
+
1494
+ root_package_manager
1495
+ end
1496
+
1497
+ def package_manager_for(package_root)
1498
+ PACKAGE_MANAGER_LOCKFILES.each do |lockfile, package_manager_name|
1499
+ return package_manager_name if File.exist?(package_root.join(lockfile))
1500
+ end
1501
+
1106
1502
  nil
1107
1503
  end
1108
1504
 
1505
+ def package_manager_root_paths
1506
+ @package_manager_root_paths ||= [javascript_package_manager_root_path, root_path].uniq
1507
+ end
1508
+
1509
+ def javascript_package_manager_root_path
1510
+ @javascript_package_manager_root_path ||= begin
1511
+ source_path = config.source_path.expand_path
1512
+ app_root = root_path.expand_path
1513
+
1514
+ if path_within?(source_path, app_root)
1515
+ current = source_path
1516
+ loop do
1517
+ break current if package_root_marker?(current)
1518
+ break root_path if current == app_root
1519
+
1520
+ parent = current.dirname
1521
+ break root_path if parent == current
1522
+
1523
+ current = parent
1524
+ end
1525
+ else
1526
+ root_path
1527
+ end
1528
+ rescue StandardError
1529
+ root_path
1530
+ end
1531
+ end
1532
+
1109
1533
  def versions_compatible?(gem_version, npm_version)
1110
1534
  # Handle pre-release versions and ranges properly
1111
1535
  npm_clean = npm_version.gsub(/[\^~]/, "")
@@ -1176,7 +1600,7 @@ module Shakapacker
1176
1600
  config_relative_path = doctor.config.config_path.relative_path_from(doctor.root_path)
1177
1601
  puts "✓ Configuration file found (#{config_relative_path})"
1178
1602
  if verbose?
1179
- puts " Assets bundler: #{doctor.config.assets_bundler}"
1603
+ puts " Assets bundler: #{doctor.send(:assets_bundler)}"
1180
1604
  puts " Source path: #{doctor.config.source_path.relative_path_from(doctor.root_path)}"
1181
1605
  puts " Public output path: #{doctor.config.public_output_path.relative_path_from(doctor.root_path)}"
1182
1606
  end
@@ -1212,9 +1636,7 @@ module Shakapacker
1212
1636
  def print_version_info
1213
1637
  return unless doctor.send(:package_json_exists?)
1214
1638
 
1215
- package_json = doctor.send(:read_package_json)
1216
- npm_version = package_json.dig("dependencies", "shakapacker") ||
1217
- package_json.dig("devDependencies", "shakapacker")
1639
+ npm_version = doctor.send(:package_json_dependency_version, "shakapacker")
1218
1640
  puts " • Shakapacker gem version: #{Shakapacker::VERSION}"
1219
1641
  puts " • Shakapacker npm version: #{npm_version || 'not installed'}"
1220
1642
  end
@@ -1275,7 +1697,7 @@ module Shakapacker
1275
1697
  end
1276
1698
 
1277
1699
  def print_transpiler_status
1278
- transpiler = doctor.config.javascript_transpiler
1700
+ transpiler = doctor.send(:javascript_transpiler)
1279
1701
  return if transpiler.nil? || transpiler == "none"
1280
1702
 
1281
1703
  loader_name = "#{transpiler}-loader"
@@ -1285,7 +1707,7 @@ module Shakapacker
1285
1707
  end
1286
1708
 
1287
1709
  def print_bundler_status
1288
- bundler = doctor.config.assets_bundler
1710
+ bundler = doctor.send(:assets_bundler)
1289
1711
  case bundler
1290
1712
  when "webpack"
1291
1713
  print_package_status("webpack", "webpack")