Skip to content

Commit 40efccd

Browse files
justin808claude
andcommitted
Add lockfile version resolution for exact version checking
Similar to shakacode/shakapacker#170, this adds support for resolving exact package versions from lockfiles (yarn.lock and package-lock.json) when checking version compatibility between the gem and npm package. Key improvements: - Adds lockfile parsing to NodePackageVersion class - Resolves exact versions from yarn.lock (v1 format) - Resolves exact versions from package-lock.json (v1, v2, v3 formats) - Falls back to package.json version if lockfiles are unavailable - Prefers yarn.lock over package-lock.json when both exist - Supports both react-on-rails and react-on-rails-pro packages This enhancement improves version constraint checking by using the exact resolved version from lockfiles instead of semver ranges in package.json, making version mismatch detection more accurate. Test coverage includes: - Yarn.lock v1 parsing - Package-lock.json v1 and v2 format parsing - Pro package version resolution - Lockfile preference order - Fallback to package.json when no lockfile exists 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 7825688 commit 40efccd

File tree

9 files changed

+328
-5
lines changed

9 files changed

+328
-5
lines changed

lib/react_on_rails/version_checker.rb

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -213,18 +213,28 @@ def package_json_location
213213
end
214214

215215
class NodePackageVersion
216-
attr_reader :package_json
216+
attr_reader :package_json, :yarn_lock, :package_lock
217217

218218
def self.build
219-
new(package_json_path)
219+
new(package_json_path, yarn_lock_path, package_lock_path)
220220
end
221221

222222
def self.package_json_path
223223
Rails.root.join(ReactOnRails.configuration.node_modules_location, "package.json")
224224
end
225225

226-
def initialize(package_json)
226+
def self.yarn_lock_path
227+
Rails.root.join(ReactOnRails.configuration.node_modules_location, "..", "yarn.lock")
228+
end
229+
230+
def self.package_lock_path
231+
Rails.root.join(ReactOnRails.configuration.node_modules_location, "..", "package-lock.json")
232+
end
233+
234+
def initialize(package_json, yarn_lock = nil, package_lock = nil)
227235
@package_json = package_json
236+
@yarn_lock = yarn_lock
237+
@package_lock = package_lock
228238
end
229239

230240
def raw
@@ -238,10 +248,16 @@ def raw
238248
deps = parsed["dependencies"]
239249

240250
# Check for react-on-rails-pro first (Pro takes precedence)
241-
return @raw = deps["react-on-rails-pro"] if deps.key?("react-on-rails-pro")
251+
if deps.key?("react-on-rails-pro")
252+
@raw = resolve_version(deps["react-on-rails-pro"], "react-on-rails-pro")
253+
return @raw
254+
end
242255

243256
# Fall back to react-on-rails
244-
return @raw = deps["react-on-rails"] if deps.key?("react-on-rails")
257+
if deps.key?("react-on-rails")
258+
@raw = resolve_version(deps["react-on-rails"], "react-on-rails")
259+
return @raw
260+
end
245261

246262
# Neither package found
247263
msg = "No 'react-on-rails' or 'react-on-rails-pro' entry in the dependencies of " \
@@ -314,6 +330,85 @@ def parts
314330

315331
private
316332

333+
# Resolve version from lockfiles if available, otherwise use package.json version
334+
def resolve_version(package_json_version, package_name)
335+
# Try yarn.lock first
336+
if yarn_lock && File.exist?(yarn_lock)
337+
lockfile_version = version_from_yarn_lock(package_name)
338+
return lockfile_version if lockfile_version
339+
end
340+
341+
# Try package-lock.json
342+
if package_lock && File.exist?(package_lock)
343+
lockfile_version = version_from_package_lock(package_name)
344+
return lockfile_version if lockfile_version
345+
end
346+
347+
# Fall back to package.json version
348+
package_json_version
349+
end
350+
351+
# Parse version from yarn.lock
352+
# Looks for entries like:
353+
# react-on-rails@^16.1.1:
354+
# version "16.1.1"
355+
# rubocop:disable Metrics/CyclomaticComplexity
356+
def version_from_yarn_lock(package_name)
357+
return nil unless yarn_lock && File.exist?(yarn_lock)
358+
359+
in_package_block = false
360+
File.foreach(yarn_lock) do |line|
361+
# Check if we're starting the block for our package
362+
if line.match?(/^"?#{Regexp.escape(package_name)}@/)
363+
in_package_block = true
364+
next
365+
end
366+
367+
# If we're in the package block, look for the version line
368+
if in_package_block
369+
# Version line looks like: version "16.1.1"
370+
if (match = line.match(/^\s+version\s+"([^"]+)"/))
371+
return match[1]
372+
end
373+
374+
# If we hit a blank line or new package, we've left the block
375+
break if line.strip.empty? || (line[0] != " " && line[0] != "\t")
376+
end
377+
end
378+
379+
nil
380+
end
381+
# rubocop:enable Metrics/CyclomaticComplexity
382+
383+
# Parse version from package-lock.json
384+
# Supports both v1 (dependencies) and v2/v3 (packages) formats
385+
# rubocop:disable Metrics/CyclomaticComplexity
386+
def version_from_package_lock(package_name)
387+
return nil unless package_lock && File.exist?(package_lock)
388+
389+
begin
390+
parsed = JSON.parse(File.read(package_lock))
391+
392+
# Try v2/v3 format first (packages)
393+
if parsed["packages"]
394+
# Look for node_modules/package-name entry
395+
node_modules_key = "node_modules/#{package_name}"
396+
return parsed["packages"][node_modules_key]["version"] if parsed["packages"][node_modules_key]
397+
end
398+
399+
# Fall back to v1 format (dependencies)
400+
if parsed["dependencies"] && parsed["dependencies"][package_name]
401+
return parsed["dependencies"][package_name]["version"]
402+
end
403+
rescue JSON::ParserError
404+
# If we can't parse the lockfile, fall back to package.json version
405+
nil
406+
end
407+
408+
nil
409+
end
410+
# rubocop:enable Metrics/CyclomaticComplexity
411+
317412
def package_installed?(package_name)
318413
return false unless File.exist?(package_json)
319414

spec/react_on_rails/fixtures/pro_semver_caret_package-lock.json

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2+
# yarn lockfile v1
3+
4+
5+
react-on-rails-pro@^16.1.1:
6+
version "16.1.1"
7+
resolved "https://registry.yarnpkg.com/react-on-rails-pro/-/react-on-rails-pro-16.1.1.tgz"
8+
integrity sha512-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz=

spec/react_on_rails/fixtures/semver_caret_package-lock.json

Lines changed: 49 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2+
# yarn lockfile v1
3+
4+
5+
babel@^6.3.26:
6+
version "6.23.0"
7+
resolved "https://registry.yarnpkg.com/babel/-/babel-6.23.0.tgz"
8+
integrity sha1-0NHn2APpdHZb7qMjLU4VPA77kPQ=
9+
10+
react-on-rails@^1.2.3:
11+
version "1.2.3"
12+
resolved "https://registry.yarnpkg.com/react-on-rails/-/react-on-rails-1.2.3.tgz"
13+
integrity sha512-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz=
14+
15+
webpack@^1.12.8:
16+
version "1.15.0"
17+
resolved "https://registry.yarnpkg.com/webpack/-/webpack-1.15.0.tgz"
18+
integrity sha1-v4SbvGJWkYqkKVBKjNJlJQQNqZg=

spec/react_on_rails/fixtures/semver_exact_package-lock.json

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"dependencies": {
3+
"react-on-rails": "16.1.1"
4+
}
5+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2+
# yarn lockfile v1
3+
4+
5+
6+
version "16.1.1"
7+
resolved "https://registry.yarnpkg.com/react-on-rails/-/react-on-rails-16.1.1.tgz"
8+
integrity sha512-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz=

spec/react_on_rails/version_checker_spec.rb

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,92 @@ def check_version_and_raise(node_package_version)
428428
end
429429
end
430430

431+
describe "Lockfile version resolution" do
432+
context "with semver caret in package.json and yarn.lock" do
433+
let(:package_json) { File.expand_path("fixtures/semver_caret_package.json", __dir__) }
434+
let(:yarn_lock) { File.expand_path("fixtures/semver_caret_yarn.lock", __dir__) }
435+
let(:node_package_version) { described_class.new(package_json, yarn_lock, nil) }
436+
437+
describe "#raw" do
438+
it "returns exact version from yarn.lock instead of semver range" do
439+
expect(node_package_version.raw).to eq("1.2.3")
440+
end
441+
end
442+
end
443+
444+
context "with semver caret in package.json and package-lock.json" do
445+
let(:package_json) { File.expand_path("fixtures/semver_caret_package.json", __dir__) }
446+
let(:package_lock) { File.expand_path("fixtures/semver_caret_package-lock.json", __dir__) }
447+
let(:node_package_version) { described_class.new(package_json, nil, package_lock) }
448+
449+
describe "#raw" do
450+
it "returns exact version from package-lock.json instead of semver range" do
451+
expect(node_package_version.raw).to eq("1.2.3")
452+
end
453+
end
454+
end
455+
456+
context "with pro package semver caret and yarn.lock" do
457+
let(:package_json) { File.expand_path("fixtures/pro_semver_caret_package.json", __dir__) }
458+
let(:yarn_lock) { File.expand_path("fixtures/pro_semver_caret_yarn.lock", __dir__) }
459+
let(:node_package_version) { described_class.new(package_json, yarn_lock, nil) }
460+
461+
describe "#raw" do
462+
it "returns exact version from yarn.lock for pro package" do
463+
expect(node_package_version.raw).to eq("16.1.1")
464+
end
465+
end
466+
end
467+
468+
context "with pro package semver caret and package-lock.json" do
469+
let(:package_json) { File.expand_path("fixtures/pro_semver_caret_package.json", __dir__) }
470+
let(:package_lock) { File.expand_path("fixtures/pro_semver_caret_package-lock.json", __dir__) }
471+
let(:node_package_version) { described_class.new(package_json, nil, package_lock) }
472+
473+
describe "#raw" do
474+
it "returns exact version from package-lock.json for pro package" do
475+
expect(node_package_version.raw).to eq("16.1.1")
476+
end
477+
end
478+
end
479+
480+
context "with exact version and yarn.lock" do
481+
let(:package_json) { File.expand_path("fixtures/semver_exact_package.json", __dir__) }
482+
let(:yarn_lock) { File.expand_path("fixtures/semver_exact_yarn.lock", __dir__) }
483+
let(:node_package_version) { described_class.new(package_json, yarn_lock, nil) }
484+
485+
describe "#raw" do
486+
it "returns exact version from yarn.lock matching package.json" do
487+
expect(node_package_version.raw).to eq("16.1.1")
488+
end
489+
end
490+
end
491+
492+
context "with semver caret but no lockfile" do
493+
let(:package_json) { File.expand_path("fixtures/semver_caret_package.json", __dir__) }
494+
let(:node_package_version) { described_class.new(package_json, nil, nil) }
495+
496+
describe "#raw" do
497+
it "falls back to package.json version when no lockfile exists" do
498+
expect(node_package_version.raw).to eq("^1.2.3")
499+
end
500+
end
501+
end
502+
503+
context "when both yarn.lock and package-lock.json exist" do
504+
let(:package_json) { File.expand_path("fixtures/semver_caret_package.json", __dir__) }
505+
let(:yarn_lock) { File.expand_path("fixtures/semver_caret_yarn.lock", __dir__) }
506+
let(:package_lock) { File.expand_path("fixtures/semver_caret_package-lock.json", __dir__) }
507+
let(:node_package_version) { described_class.new(package_json, yarn_lock, package_lock) }
508+
509+
describe "#raw" do
510+
it "prefers yarn.lock over package-lock.json" do
511+
expect(node_package_version.raw).to eq("1.2.3")
512+
end
513+
end
514+
end
515+
end
516+
431517
describe "Pro package detection" do
432518
context "with react-on-rails package" do
433519
let(:package_json) { File.expand_path("fixtures/normal_package.json", __dir__) }

0 commit comments

Comments
 (0)