pivotal/LicenseFinder

View on GitHub
lib/license_finder/packages/npm_package.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

module LicenseFinder
  class NpmPackage < Package
    attr_accessor :identifier, :dependencies, :groups, :json

    class << self
      def packages_from_json(npm_json, package_path)
        @packages = flattened_dependencies(npm_json)
        package_json = PackageJson.new(package_path)
        populate_groups(package_json)
        @packages.reject! do |_identifier, package|
          package.name.empty? &&
            package.version.empty? &&
            package.licenses.length == 1 &&
            package.licenses.first.name == 'unknown'
        end
        @packages.values
      end

      private

      def flattened_dependencies(npm_json, existing_packages = {})
        identifier = Identifier.from_hash npm_json
        if existing_packages[identifier].nil?
          existing_packages[identifier] = package_for_dependency(npm_json) if identifier
          npm_json.fetch('dependencies', {}).values.map do |d|
            flattened_dependencies(d, existing_packages)
          end
        else
          duplicate_package = package_for_dependency(npm_json)
          unless existing_packages[identifier].dependencies.include?(duplicate_package.dependencies)
            existing_packages[identifier].dependencies |= duplicate_package.dependencies
            npm_json.fetch('dependencies', {}).values.map do |d|
              flattened_dependencies(d, existing_packages)
            end
          end
        end
        existing_packages
      end

      # Read the dependency's package.json file in order to get details like the license, authors,
      # and so on. In NPM versions < 7, this information was included in the output of `npm list`.
      # In later versions, it no longer is, and has to be read from the package.json file instead.
      def package_for_dependency(npm_json)
        package_path = npm_json['path']
        package_json_path = Pathname.new(package_path).join('package.json') unless package_path.nil?

        if package_json_path.nil? || !package_json_path.exist?
          # Ancient NPM versions did not have the "path" field. Resort to the old way of gathering
          # the details, expecting them to be contained in the output of `npm list`.
          NpmPackage.new(npm_json)
        else
          package_json = JSON.parse(package_json_path.read, max_nesting: false)
          NpmPackage.new(npm_json, package_json)
        end
      end

      def populate_groups(package_json)
        package_json.groups.each do |group|
          group.package_names.each do |package_name|
            @packages.each_key do |identifier|
              next unless identifier.name == package_name

              dependency = @packages[identifier]
              dependency.groups |= [group.name]
              populate_child_groups(dependency, @packages)
            end
          end
        end
      end

      def populate_child_groups(dependency, packages, populated_ids = [])
        dependency.dependencies.each do |id|
          next if populated_ids.include? id

          populated_ids.push id
          packages[id].groups |= dependency.groups
          populate_child_groups(packages[id], packages, populated_ids)
        end
      end
    end

    def initialize(npm_json, package_json = npm_json)
      @npm_json = npm_json
      @json = package_json
      @identifier = Identifier.from_hash(npm_json)
      @dependencies = deps_from_json
      super(@identifier.name,
            @identifier.version,
            description: package_json['description'],
            homepage: package_json['homepage'],
            authors: author_names,
            spec_licenses: Package.license_names_from_standard_spec(package_json),
            install_path: npm_json['path'],
            children: @dependencies.map(&:name))
    end

    def author_names
      names = []
      if @json['author'].is_a?(Array)
        # "author":["foo","bar"] isn't valid according to the NPM package.json schema, but can be found in the wild.
        names += @json['author'].map { |a| author_name(a) }
      else
        names << author_name(@json['author']) unless @json['author'].nil?
      end
      names += @json['contributors'].map { |c| author_name(c) } if @json['contributors'].is_a?(Array)
      names.compact.join(', ')
    rescue TypeError
      puts "Warning: Invalid author and/or contributors metadata found in package.json for #{@identifier}"
      nil
    end

    def author_name(author)
      if author.instance_of?(String)
        author_name_from_combined(author)
      else
        author['name']
      end
    end

    def author_name_from_combined(author)
      matches = author.match /^(.*?)\s*(<.*?>)?\s*(\(.*?\))?\s*$/
      matches[1]
    end

    def ==(other)
      other.is_a?(NpmPackage) && @identifier == other.identifier
    end

    def to_s
      @identifier.to_s
    end

    def package_manager
      'Npm'
    end

    def package_url
      "https://www.npmjs.com/package/#{CGI.escape(name)}/v/#{CGI.escape(version)}"
    end

    private

    def deps_from_json
      @npm_json.fetch('dependencies', {}).values.map { |dep| Identifier.from_hash(dep) }.compact
    end

    class Identifier
      attr_accessor :name, :version

      def initialize(name, version)
        @name = name
        @version = version
      end

      def self.from_hash(hash)
        name = hash['name']
        version = hash['version']
        return nil if name.nil? || name.empty? || version.nil? || version.empty?

        Identifier.new(name, version)
      end

      def ==(other)
        other.is_a?(Identifier) && @name == other.name && @version == other.version
      end

      def eql?(other)
        self == other
      end

      def hash
        [@name, @version].hash
      end

      def <=>(other)
        sort_name = @name <=> other.name
        sort_name.zero? ? @version <=> other.version : sort_name
      end

      def to_s
        "#{@name} - #{@version}"
      end
    end

    class Group
      attr_accessor :name, :package_names

      def initialize(name, hash)
        @name = name
        @package_names = hash.keys
      end

      def include?(identifier)
        @package_names.include? identifier.name
      end

      def to_s
        @name
      end
    end

    class PackageJson
      attr_reader :groups

      DEPENDENCY_GROUPS = %w[dependencies devDependencies].freeze

      def initialize(path)
        json = JSON.parse(File.read(path), max_nesting: false)
        @groups = DEPENDENCY_GROUPS.map { |name| Group.new(name, json.fetch(name, {})) }
      end

      def groups_for(identifier)
        @groups.select { |g| g.include? identifier }.map(&:name)
      end
    end
  end
end