Skip to content

Warn about vendored versionless packages #305

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 41 additions & 11 deletions lib/importmap/npm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,22 @@
require "json"

class Importmap::Npm
PIN_REGEX = /^pin ["']([^["']]*)["'].*/

Error = Class.new(StandardError)
HTTPError = Class.new(Error)

singleton_class.attr_accessor :base_uri
self.base_uri = URI("https://registry.npmjs.org")

def initialize(importmap_path = "config/importmap.rb")
def initialize(importmap_path = "config/importmap.rb", vendor_path: "vendor/javascript")
@importmap_path = Pathname.new(importmap_path)
@vendor_path = Pathname.new(vendor_path)
end

def outdated_packages
packages_with_versions.each.with_object([]) do |(package, current_version), outdated_packages|
outdated_package = OutdatedPackage.new(name: package,
current_version: current_version)
outdated_package = OutdatedPackage.new(name: package, current_version: current_version)

if !(response = get_package(package))
outdated_package.error = 'Response error'
Expand All @@ -36,28 +38,33 @@ def outdated_packages
def vulnerable_packages
get_audit.flat_map do |package, vulnerabilities|
vulnerabilities.map do |vulnerability|
VulnerablePackage.new(name: package,
severity: vulnerability['severity'],
vulnerable_versions: vulnerability['vulnerable_versions'],
vulnerability: vulnerability['title'])
VulnerablePackage.new(
name: package,
severity: vulnerability['severity'],
vulnerable_versions: vulnerability['vulnerable_versions'],
vulnerability: vulnerability['title']
)
end
end.sort_by { |p| [p.name, p.severity] }
end

def packages_with_versions
# We cannot use the name after "pin" because some dependencies are loaded from inside packages
# Eg. pin "buffer", to: "https://ga.jspm.io/npm:@jspm/[email protected]/nodelibs/browser/buffer.js"
with_versions = importmap.scan(/^pin .*(?<=npm:|npm\/|skypack\.dev\/|unpkg\.com\/)(.*)(?=@\d+\.\d+\.\d+)@(\d+\.\d+\.\d+(?:[^\/\s["']]*)).*$/) |
importmap.scan(/#{PIN_REGEX} #.*@(\d+\.\d+\.\d+(?:[^\s]*)).*$/)

vendored_packages_without_version(with_versions).each do |package, path|
$stdout.puts "Ignoring #{package} (#{path}) since no version is specified in the importmap"
end

importmap.scan(/^pin .*(?<=npm:|npm\/|skypack\.dev\/|unpkg\.com\/)(.*)(?=@\d+\.\d+\.\d+)@(\d+\.\d+\.\d+(?:[^\/\s["']]*)).*$/) |
importmap.scan(/^pin ["']([^["']]*)["'].* #.*@(\d+\.\d+\.\d+(?:[^\s]*)).*$/)
with_versions
end

private
OutdatedPackage = Struct.new(:name, :current_version, :latest_version, :error, keyword_init: true)
VulnerablePackage = Struct.new(:name, :severity, :vulnerable_versions, :vulnerability, keyword_init: true)



def importmap
@importmap ||= File.read(@importmap_path)
end
Expand Down Expand Up @@ -130,4 +137,27 @@ def post_json(uri, body)
rescue => error
raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})"
end

def vendored_packages_without_version(packages_with_versions)
versioned_packages = packages_with_versions.map(&:first).to_set

importmap
.lines
.filter_map { |line| find_unversioned_vendored_package(line, versioned_packages) }
end

def find_unversioned_vendored_package(line, versioned_packages)
regexp = line.include?("to:")? /#{PIN_REGEX}to: ["']([^["']]*)["'].*/ : PIN_REGEX
match = line.match(regexp)

return unless match

package, filename = match.captures
filename ||= "#{package}.js"

return if versioned_packages.include?(package)

path = File.join(@vendor_path, filename)
[package, path] if File.exist?(path)
end
end
2 changes: 2 additions & 0 deletions test/fixtures/files/import_map_without_cdn_and_versions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pin "foo", preload: true
pin "@bar/baz", to: "baz.js", preload: true
33 changes: 18 additions & 15 deletions test/npm_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,24 +46,21 @@ class Importmap::NpmTest < ActiveSupport::TestCase
end
end

test "missing outdated packages with mock" do
response = { "error" => "Not found" }.to_json
test "warns (and ignores) vendored packages without version" do
Dir.mktmpdir do |vendor_path|
foo_path = create_vendored_file(vendor_path, "foo.js")
baz_path = create_vendored_file(vendor_path, "baz.js")

@npm.stub(:get_json, response) do
outdated_packages = @npm.outdated_packages
npm = Importmap::Npm.new(file_fixture("import_map_without_cdn_and_versions.rb"), vendor_path: vendor_path)

assert_equal(1, outdated_packages.size)
assert_equal('md5', outdated_packages[0].name)
assert_equal('2.2.0', outdated_packages[0].current_version)
assert_equal('Not found', outdated_packages[0].error)
end
end
outdated_packages = []
stdout, _stderr = capture_io { outdated_packages = npm.outdated_packages }

test "failed outdated packages request with exception" do
Net::HTTP.stub(:start, proc { raise "Unexpected Error" }) do
assert_raises(Importmap::Npm::HTTPError) do
@npm.outdated_packages
end
assert_equal(<<~OUTPUT, stdout)
Ignoring foo (#{foo_path}) since no version is specified in the importmap
Ignoring @bar/baz (#{baz_path}) since no version is specified in the importmap
OUTPUT
assert_equal(0, outdated_packages.size)
end
end

Expand Down Expand Up @@ -142,4 +139,10 @@ def code() "200" end
assert_equal('version not found', outdated_packages[0].latest_version)
end
end

def create_vendored_file(dir, name)
path = File.join(dir, name)
File.write(path, "console.log(123)")
path
end
end