diff --git a/SOURCES/nodejs.attr b/SOURCES/nodejs.attr
index a04c30e..303badb 100644
--- a/SOURCES/nodejs.attr
+++ b/SOURCES/nodejs.attr
@@ -1,3 +1,3 @@
-%__nodejs10_provides  %{_rpmconfigdir}/nodejs10.prov
-%__nodejs10_requires  %{_rpmconfigdir}/nodejs10.req
-%__nodejs10_path  ^/usr/lib(64)?/node_modules/[^/]+/package\\.json$
+%__rh_nodejs10_provides  %{_rpmconfigdir}/rh_nodejs10.prov
+%__rh_nodejs10_requires  %{_rpmconfigdir}/rh_nodejs10.req
+%__rh_nodejs10_path  ^(/opt/rh/rh-nodejs10/root)?/usr/lib(64)?/node_modules/[^/]+/package\\.json$
diff --git a/SOURCES/nodejs.prov b/SOURCES/nodejs.prov
index 5fe4498..45f5057 100755
--- a/SOURCES/nodejs.prov
+++ b/SOURCES/nodejs.prov
@@ -1,12 +1,8 @@
 #!/usr/bin/python
-
-"""
-Automatic provides generator for Node.js libraries.
-
-Taken from package.json.  See `man npm-json` for details.
-"""
+# -*- coding: utf-8 -*-
 # Copyright 2012 T.C. Hollingsworth <tchollingsworth@gmail.com>
 # Copyright 2017 Tomas Tomecek <ttomecek@redhat.com>
+# Copyright 2019 Jan Staněk <jstanek@redhat.com>
 #
 # Permission is hereby granted, free of charge, to any person obtaining a copy
 # of this software and associated documentation files (the "Software"), to
@@ -26,73 +22,100 @@ Taken from package.json.  See `man npm-json` for details.
 # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
 # IN THE SOFTWARE.
 
-from __future__ import print_function
+"""Automatic provides generator for Node.js libraries.
 
+Metadata taken from package.json.  See `man npm-json` for details.
+"""
+
+from __future__ import print_function, with_statement
+
+import json
 import os
 import sys
-import json
+from itertools import chain, groupby
 
-provides = set()
+DEPENDENCY_TEMPLATE = "rh-nodejs10-npm(%(name)s) = %(version)s"
+BUNDLED_TEMPLATE = "bundled(nodejs-%(name)s) = %(version)s"
+NODE_MODULES = {"node_modules", "node_modules.bundled"}
 
-def handle_package_json(path, bundled=False):
-    """
-    process package.json file available on path, print RPM dependency based on name and version
-    """
-    if not path.endswith('package.json') or not os.path.isfile(path):
-        return
-    fh = open(path)
-    metadata = json.load(fh)
-    fh.close()
-
-    try:
-        if metadata['private']:
-            return
-    except KeyError:
-        pass
-
-    try:
-        name = metadata["name"]
-    except KeyError:
-        return
-    try:
-        version = metadata["version"]
-    except KeyError:
-        return
-
-    if bundled:
-        value = "bundled(nodejs-%s) = %s" % (name, version)
-    else:
-        value = "rh-nodejs10-npm(%s) = %s" % (name, version)
-    provides.add(value)
-
-
-def handle_module(path, bundled):
-    """
-    process npm module and all its bundled dependencies
+
+class PrivatePackage(RuntimeError):
+    """Private package metadata that should not be listed."""
+
+
+#: Something is wrong with the ``package.json`` file
+_INVALID_METADATA_FILE = (IOError, PrivatePackage, KeyError)
+
+
+def format_metadata(metadata, bundled=False):
+    """Format ``package.json``-like metadata into RPM dependency.
+
+    Arguments:
+        metadata (dict): Package metadata, presumably read from ``package.json``.
+        bundled (bool): Should the bundled dependency format be used?
+
+    Returns:
+        str: RPM dependency (i.e. ``npm(example) = 1.0.0``)
+
+    Raises:
+        KeyError: Expected key (i.e. ``name``, ``version``) missing in metadata.
+        PrivatePackage: The metadata indicate private (unlisted) package.
     """
-    handle_package_json(path, bundled=bundled)
-    if not os.path.isdir(path):
-        path = os.path.dirname(path)
-    node_modules_dir_candidate = os.path.join(path, "node_modules")
-    if os.path.isdir(node_modules_dir_candidate):
-        for module_path in os.listdir(node_modules_dir_candidate):
-            module_abs_path = os.path.join(node_modules_dir_candidate, module_path)
-            # skip modules which are linked against system module
-            if not os.path.islink(module_abs_path):
-                p_json_file = os.path.join(module_abs_path, "package.json")
-                handle_module(p_json_file, bundled=True)
-
-
-def main():
-    """ read list of package.json paths from stdin """
-    paths = [path.rstrip() for path in sys.stdin.readlines()]
-
-    for path in paths:
-        handle_module(path, bundled=False)
-
-    for provide in sorted(provides):
-        print(provide)
 
+    # Skip private packages
+    if metadata.get("private", False):
+        raise PrivatePackage(metadata)
+
+    template = BUNDLED_TEMPLATE if bundled else DEPENDENCY_TEMPLATE
+    return template % metadata
 
-if __name__ == '__main__':
-    main()
+
+def generate_dependencies(module_path, module_dir_set=NODE_MODULES):
+    """Generate RPM dependency for a module and all it's dependencies.
+
+    Arguments:
+        module_path (str): Path to a module directory or it's ``package.json``
+        module_dir_set (set): Base names of directories to look into
+            for bundled dependencies.
+
+    Yields:
+        str: RPM dependency for the module and each of it's (public) bundled dependencies.
+
+    Raises:
+        ValueError: module_path is not valid module or ``package.json`` file
+    """
+
+    # Determine paths to root module directory and package.json
+    if os.path.isdir(module_path):
+        root_dir = module_path
+    elif os.path.basename(module_path) == "package.json":
+        root_dir = os.path.dirname(module_path)
+    else:  # Invalid metadata path
+        raise ValueError("Invalid module path '%s'" % module_path)
+
+    for dir_path, subdir_list, __ in os.walk(root_dir):
+        # Currently in node_modules (or similar), continue to subdirs
+        if os.path.basename(dir_path) in module_dir_set:
+            continue
+
+        # Read and format metadata
+        metadata_path = os.path.join(dir_path, "package.json")
+        bundled = dir_path != root_dir
+        try:
+            with open(metadata_path, mode="r") as metadata_file:
+                metadata = json.load(metadata_file)
+            yield format_metadata(metadata, bundled=bundled)
+        except _INVALID_METADATA_FILE:
+            pass  # Ignore
+
+        # Only visit subdirectories in module_dir_set
+        subdir_list[:] = list(module_dir_set & set(subdir_list))
+
+
+if __name__ == "__main__":
+    module_paths = (path.strip() for path in sys.stdin)
+    provides = chain.from_iterable(generate_dependencies(m) for m in module_paths)
+
+    # sort|uniq
+    for provide, __ in groupby(sorted(provides)):
+        print(provide)
diff --git a/SOURCES/nodejs.req b/SOURCES/nodejs.req
index acd96d8..76ca68f 100755
--- a/SOURCES/nodejs.req
+++ b/SOURCES/nodejs.req
@@ -1,12 +1,7 @@
 #!/usr/bin/python
-
-"""
-Automatic dependency generator for Node.js libraries.
-
-Parsed from package.json.  See `man npm-json` for details.
-"""
-
+# -*- coding: utf-8 -*-
 # Copyright 2012, 2013 T.C. Hollingsworth <tchollingsworth@gmail.com>
+# Copyright 2019 Jan Staněk <jstanek@redat.com>
 #
 # Permission is hereby granted, free of charge, to any person obtaining a copy
 # of this software and associated documentation files (the "Software"), to
@@ -26,270 +21,692 @@ Parsed from package.json.  See `man npm-json` for details.
 # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
 # IN THE SOFTWARE.
 
-from __future__ import unicode_literals, print_function
+""" Automatic dependency generator for Node.js libraries.
+
+Metadata parsed from package.json.  See `man npm-json` for details.
+"""
+
+from __future__ import print_function, with_statement
+
 import json
+import operator
 import os
 import re
 import sys
+from collections import namedtuple
+from itertools import chain
+from itertools import takewhile
 
-def has_all_bundled(path):
-    # remove 'package.json'
-    path = os.path.dirname(path)
-    node_modules_dir_candidate = os.path.join(path, "node_modules")
-    if os.path.isdir(node_modules_dir_candidate):
-        modules_abs_path = map(lambda x: os.path.join(node_modules_dir_candidate, x),
-                               os.listdir(node_modules_dir_candidate))
-        any_link = any([os.path.islink(x) for x in modules_abs_path])
-        return not any_link
+# Python version detection
+_PY2 = sys.version_info[0] <= 2
+_PY3 = sys.version_info[0] >= 3
 
+if _PY2:
+    from future_builtins import map, filter
 
-def main():
-    #npm2rpm uses functions here to write BuildRequires so don't print anything
-    #until the very end
-    deps = []
 
-    #it's highly unlikely that we'll ever get more than one file but we handle
-    #this like all RPM automatic dependency generation scripts anyway
-    paths = [path.rstrip() for path in sys.stdin.readlines()]
+#: Name format of the requirements
+REQUIREMENT_NAME_TEMPLATE = "npm({name})"
 
-    for path in paths:
-        if not path.endswith('package.json'):
-            continue
+#: ``simple`` product of the NPM semver grammar.
+RANGE_SPECIFIER_SIMPLE = re.compile(
+    r"""
+    (?P<operator>
+        <= | >= | < | > | =     # primitive
+        | ~ | \^                # tilde/caret operators
+    )?
+    \s*(?P<version>\S+)\s*  # version specifier
+    """,
+    flags=re.VERBOSE,
+)
 
-        if has_all_bundled(path):
-            continue
 
-        fh = open(path)
-        metadata = json.load(fh)
-        fh.close()
+class UnsupportedVersionToken(ValueError):
+    """Version specifier contains token unsupported by the parser."""
 
-        if '--optional' in sys.argv:
-            deptype = 'optionalDependencies'
-        else:
-            deptype = 'dependencies'
-
-        if deptype == 'dependencies':
-            #write out the node.js interpreter dependency
-            req = 'rh-nodejs10-nodejs(engine)'
-
-            if 'engines' in metadata and isinstance(metadata['engines'], dict) \
-                                                and 'node' in metadata['engines']:
-                deps.append(process_dep(req, metadata['engines']['node']))
-            else:
-                deps.append(req)
-
-        if deptype in metadata:
-            if isinstance(metadata[deptype], dict):
-                for name, version in metadata[deptype].items():
-                    req = 'rh-nodejs10-npm(' + name + ')'
-                    deps.append(process_dep(req, version))
-            elif isinstance(metadata[deptype], list):
-                for name in metadata[deptype]:
-                    req = 'rh-nodejs10-npm(' + name + ')'
-                    deps.append(req)
-            elif isinstance(metadata[deptype], str):
-                req = 'rh-nodejs10-npm(' + metadata[deptype] + ')'
-                deps.append(req)
-            else:
-                raise TypeError('invalid package.json: dependencies not a valid type')
-
-    print('\n'.join(deps))
-
-def process_dep(req, version):
-    """Converts an individual npm dependency into RPM dependencies"""
-
-    #there's no way RPM can do anything like an OR dependency
-    if '||' in version:
-        sys.stderr.write("WARNING: The {0} dependency contains an ".format(req) +
-            "OR (||) dependency: '{0}.\nPlease manually include ".format(version) +
-            "a versioned dependency in your spec file if necessary")
-        return req
-
-    if ' - ' in version:
-        version = expand_hyphen_range(version)
-
-    deps = convert_dep(req, version)
-
-    if len(deps) > 1:
-        dep = "(" + " with ".join(deps) + ")"
-    else:
-        dep = deps[0]
 
-    return dep
+class Version(tuple):
+    """Normalized RPM/NPM version.
 
-def parse_version(v):
-    """
-    Parse an individual version number like 1.2.3 into a tuple.
-        '1.2.3' -> (1, 2, 3)
-        '1.2'   -> (1, 2)
-        '1'     -> (1,)
-        '*'     -> ()
-    This is the "partial" production in the grammar:
-    https://docs.npmjs.com/misc/semver#range-grammar
+    The version has up to 3 components – major, minor, patch.
+    Any part set to None is treated as unspecified.
+
+    ::
+
+        1.2.3 == Version(1, 2, 3)
+        1.2   == Version(1, 2)
+        1     == Version(1)
+        *     == Version()
     """
-    # Ignore leading 'v'
-    v = v.lstrip('v')
-    parts = v.split('.', 3)
-    if parts[0] in ['', 'x', 'X', '*']:
-        return ()
-    if len(parts) < 2 or parts[1] in ['', 'x', 'X', '*']:
-        return (int(parts[0]),)
-    if len(parts) < 3 or parts[2] in ['', 'x', 'X', '*']:
-        return (int(parts[0]), int(parts[1]))
-    # Strip off and discard any pre-release or build qualifiers at the end.
-    # We can get away with this, because there is no sane way to represent
-    # these kinds of version requirements in RPM, and we generally expect
-    # the distro will only carry proper releases anyway.
-    return (int(parts[0]),
-            int(parts[1]),
-            int(''.join(c for c in parts[2] if c.isdigit())))
-
-def incremented(v):
+
+    __slots__ = ()
+
+    #: Version part meaning 'Any'
+    #: ``xr`` in https://docs.npmjs.com/misc/semver#range-grammar
+    _PART_ANY = re.compile(r"^[xX*]$")
+    #: Numeric version part
+    #: ``nr`` in https://docs.npmjs.com/misc/semver#range-grammar
+    _PART_NUMERIC = re.compile(r"0|[1-9]\d*")
+
+    def __new__(cls, *args):
+        """Create new version.
+
+        Arguments:
+            Version components in the order of "major", "minor", "patch".
+            All parts are optional::
+
+                >>> Version(1, 2, 3)
+                Version(1, 2, 3)
+                >>> Version(1)
+                Version(1)
+                >>> Version()
+                Version()
+
+        Returns:
+            New Version.
+        """
+
+        if len(args) > 3:
+            raise ValueError("Version has maximum of 3 components")
+        return super(Version, cls).__new__(cls, map(int, args))
+
+    def __repr__(self):
+        """Pretty debugging format."""
+
+        return "{0}({1})".format(self.__class__.__name__, ", ".join(map(str, self)))
+
+    def __str__(self):
+        """RPM version format."""
+
+        return ".".join(format(part, "d") for part in self)
+
+    @property
+    def major(self):
+        """Major version number, if any."""
+        return self[0] if len(self) > 0 else None
+
+    @property
+    def minor(self):
+        """Major version number, if any."""
+        return self[1] if len(self) > 1 else None
+
+    @property
+    def patch(self):
+        """Major version number, if any."""
+        return self[2] if len(self) > 2 else None
+
+    @property
+    def empty(self):
+        """True if the version contains nothing but zeroes."""
+        return not any(self)
+
+    @classmethod
+    def parse(cls, version_string):
+        """Parse individual version string (like ``1.2.3``) into Version.
+
+        This is the ``partial`` production in the grammar:
+        https://docs.npmjs.com/misc/semver#range-grammar
+
+        Examples::
+
+            >>> Version.parse("1.2.3")
+            Version(1, 2, 3)
+            >>> Version.parse("v2.x")
+            Version(2)
+            >>> Version.parse("")
+            Version()
+
+        Arguments:
+            version_string (str): The version_string to parse.
+
+        Returns:
+            Version: Parsed result.
+        """
+
+        # Ignore leading ``v``, if any
+        version_string = version_string.lstrip("v")
+
+        part_list = version_string.split(".", 2)
+        # Use only parts up to first "Any" indicator
+        part_list = list(takewhile(lambda p: not cls._PART_ANY.match(p), part_list))
+
+        if not part_list:
+            return cls()
+
+        # Strip off and discard any pre-release or build qualifiers at the end.
+        # We can get away with this, because there is no sane way to represent
+        # these kinds of version requirements in RPM, and we generally expect
+        # the distro will only carry proper releases anyway.
+        try:
+            part_list[-1] = cls._PART_NUMERIC.match(part_list[-1]).group()
+        except AttributeError:  # no match
+            part_list.pop()
+
+        # Extend with ``None``s at the end, if necessary
+        return cls(*part_list)
+
+    def incremented(self):
+        """Increment the least significant part of the version::
+
+            >>> Version(1, 2, 3).incremented()
+            Version(1, 2, 4)
+            >>> Version(1, 2).incremented()
+            Version(1, 3)
+            >>> Version(1).incremented()
+            Version(2)
+            >>> Version().incremented()
+            Version()
+
+        Returns:
+            Version: New incremented Version.
+        """
+
+        if len(self) == 0:
+            return self.__class__()
+        else:
+            args = self[:-1] + (self[-1] + 1,)
+            return self.__class__(*args)
+
+
+class VersionBoundary(namedtuple("VersionBoundary", ("version", "operator"))):
+    """Normalized version range boundary."""
+
+    __slots__ = ()
+
+    #: Ordering of primitive operators.
+    #: Operators not listed here are handled specially; see __compare below.
+    #: Convention: Lower boundary < 0, Upper boundary > 0
+    _OPERATOR_ORDER = {"<": 2, "<=": 1, ">=": -1, ">": -2}
+
+    def __str__(self):
+        """Pretty-print the boundary"""
+
+        return "{0.operator}{0.version}".format(self)
+
+    def __compare(self, other, operator):
+        """Compare two boundaries with provided operator.
+
+        Boundaries compare same as (version, operator_order) tuple.
+        In case the boundary operator is not listed in _OPERATOR_ORDER,
+        it's order is treated as 0.
+
+        Arguments:
+            other (VersionBoundary): The other boundary to compare with.
+            operator (Callable[[VersionBoundary, VersionBoundary], bool]):
+                Comparison operator to delegate to.
+
+        Returns:
+            bool: The result of the operator's comparison.
+        """
+
+        ORDER = self._OPERATOR_ORDER
+
+        lhs = self.version, ORDER.get(self.operator, 0)
+        rhs = other.version, ORDER.get(other.operator, 0)
+        return operator(lhs, rhs)
+
+    def __eq__(self, other):
+        return self.__compare(other, operator.eq)
+
+    def __lt__(self, other):
+        return self.__compare(other, operator.lt)
+
+    def __le__(self, other):
+        return self.__compare(other, operator.le)
+
+    def __gt__(self, other):
+        return self.__compare(other, operator.gt)
+
+    def __ge__(self, other):
+        return self.__compare(other, operator.ge)
+
+    @property
+    def upper(self):
+        """True if self is upper boundary."""
+        return self._OPERATOR_ORDER.get(self.operator, 0) > 0
+
+    @property
+    def lower(self):
+        """True if self is lower boundary."""
+        return self._OPERATOR_ORDER.get(self.operator, 0) < 0
+
+    @classmethod
+    def equal(cls, version):
+        """Normalize single samp:`={version}` into equivalent x-range::
+
+            >>> empty = VersionBoundary.equal(Version()); tuple(map(str, empty))
+            ()
+            >>> patch = VersionBoundary.equal(Version(1, 2, 3)); tuple(map(str, patch))
+            ('>=1.2.3', '<1.2.4')
+            >>> minor = VersionBoundary.equal(Version(1, 2)); tuple(map(str, minor))
+            ('>=1.2', '<1.3')
+            >>> major = VersionBoundary.equal(Version(1)); tuple(map(str, major))
+            ('>=1', '<2')
+
+        See `X-Ranges <https://docs.npmjs.com/misc/semver#x-ranges-12x-1x-12->`_
+        for details.
+
+        Arguments:
+            version (Version): The version the x-range should be equal to.
+
+        Returns:
+            (VersionBoundary, VersionBoundary):
+                Lower and upper bound of the x-range.
+            (): Empty tuple in case version is empty (any version matches).
+        """
+
+        if version:
+            return (
+                cls(version=version, operator=">="),
+                cls(version=version.incremented(), operator="<"),
+            )
+        else:
+            return ()
+
+    @classmethod
+    def tilde(cls, version):
+        """Normalize :samp:`~{version}` into equivalent range.
+
+        Tilde allows patch-level changes if a minor version is specified.
+        Allows minor-level changes if not::
+
+            >>> with_minor = VersionBoundary.tilde(Version(1, 2, 3)); tuple(map(str, with_minor))
+            ('>=1.2.3', '<1.3')
+            >>> no_minor = VersionBoundary.tilde(Version(1)); tuple(map(str, no_minor))
+            ('>=1', '<2')
+
+        Arguments:
+            version (Version): The version to tilde-expand.
+
+        Returns:
+            (VersionBoundary, VersionBoundary):
+                The lower and upper boundary of the tilde range.
+        """
+
+        # Fail on ``~*`` or similar nonsense specifier
+        assert version.major is not None, "Nonsense '~*' specifier"
+
+        lower_boundary = cls(version=version, operator=">=")
+
+        if version.minor is None:
+            upper_boundary = cls(version=Version(version.major + 1), operator="<")
+        else:
+            upper_boundary = cls(
+                version=Version(version.major, version.minor + 1), operator="<"
+            )
+
+        return lower_boundary, upper_boundary
+
+    @classmethod
+    def caret(cls, version):
+        """Normalize :samp:`^{version}` into equivalent range.
+
+        Caret allows changes that do not modify the left-most non-zero digit
+        in the ``(major, minor, patch)`` tuple.
+        In other words, this allows
+        patch and minor updates for versions 1.0.0 and above,
+        patch updates for versions 0.X >=0.1.0,
+        and no updates for versions 0.0.X::
+
+            >>> major = VersionBoundary.caret(Version(1, 2, 3)); tuple(map(str, major))
+            ('>=1.2.3', '<2')
+            >>> minor = VersionBoundary.caret(Version(0, 2, 3)); tuple(map(str, minor))
+            ('>=0.2.3', '<0.3')
+            >>> patch = VersionBoundary.caret(Version(0, 0, 3)); tuple(map(str, patch))
+            ('>=0.0.3', '<0.0.4')
+
+        When parsing caret ranges, a missing patch value desugars to the number 0,
+        but will allow flexibility within that value,
+        even if the major and minor versions are both 0::
+
+            >>> rel = VersionBoundary.caret(Version(1, 2)); tuple(map(str, rel))
+            ('>=1.2', '<2')
+            >>> pre = VersionBoundary.caret(Version(0, 0)); tuple(map(str, pre))
+            ('>=0.0', '<0.1')
+
+        A missing minor and patch values will desugar to zero,
+        but also allow flexibility within those values,
+        even if the major version is zero::
+
+            >>> rel = VersionBoundary.caret(Version(1)); tuple(map(str, rel))
+            ('>=1', '<2')
+            >>> pre = VersionBoundary.caret(Version(0)); tuple(map(str, pre))
+            ('>=0', '<1')
+
+        Arguments:
+            version (Version): The version to range-expand.
+
+        Returns:
+            (VersionBoundary, VersionBoundary):
+                The lower and upper boundary of caret-range.
+        """
+
+        # Fail on ^* or similar nonsense specifier
+        assert len(version) != 0, "Nonsense '^*' specifier"
+
+        lower_boundary = cls(version=version, operator=">=")
+
+        # Increment left-most non-zero part
+        for idx, part in enumerate(version):
+            if part != 0:
+                upper_version = Version(*(version[:idx] + (part + 1,)))
+                break
+        else:  # No non-zero found; increment last specified part
+            upper_version = version.incremented()
+
+        upper_boundary = cls(version=upper_version, operator="<")
+
+        return lower_boundary, upper_boundary
+
+    @classmethod
+    def hyphen(cls, lower_version, upper_version):
+        """Construct hyphen range (inclusive set)::
+
+            >>> full = VersionBoundary.hyphen(Version(1, 2, 3), Version(2, 3, 4)); tuple(map(str, full))
+            ('>=1.2.3', '<=2.3.4')
+
+        If a partial version is provided as the first version in the inclusive range,
+        then the missing pieces are treated as zeroes::
+
+            >>> part = VersionBoundary.hyphen(Version(1, 2), Version(2, 3, 4)); tuple(map(str, part))
+            ('>=1.2', '<=2.3.4')
+
+        If a partial version is provided as the second version in the inclusive range,
+        then all versions that start with the supplied parts of the tuple are accepted,
+        but nothing that would be greater than the provided tuple parts::
+
+            >>> part = VersionBoundary.hyphen(Version(1, 2, 3), Version(2, 3)); tuple(map(str, part))
+            ('>=1.2.3', '<2.4')
+            >>> part = VersionBoundary.hyphen(Version(1, 2, 3), Version(2)); tuple(map(str, part))
+            ('>=1.2.3', '<3')
+
+        Arguments:
+            lower_version (Version): Version on the lower range boundary.
+            upper_version (Version): Version on the upper range boundary.
+
+        Returns:
+            (VersionBoundary, VersionBoundary):
+                Lower and upper boundaries of the hyphen range.
+        """
+
+        lower_boundary = cls(version=lower_version, operator=">=")
+
+        if len(upper_version) < 3:
+            upper_boundary = cls(version=upper_version.incremented(), operator="<")
+        else:
+            upper_boundary = cls(version=upper_version, operator="<=")
+
+        return lower_boundary, upper_boundary
+
+
+def parse_simple_seq(specifier_string):
+    """Parse all specifiers from a space-separated string::
+
+        >>> single = parse_simple_seq(">=1.2.3"); list(map(str, single))
+        ['>=1.2.3']
+        >>> multi = parse_simple_seq("~1.2.0 <1.2.5"); list(map(str, multi))
+        ['>=1.2.0', '<1.3', '<1.2.5']
+
+    This method implements the ``simple (' ' simple)*`` part of the grammar:
+    https://docs.npmjs.com/misc/semver#range-grammar.
+
+    Arguments:
+        specifier_string (str): Space-separated string of simple version specifiers.
+
+    Yields:
+        VersionBoundary: Parsed boundaries.
     """
-    Returns the given version tuple with the last part incremented.
-        (1, 2, 3) -> (1, 2, 4)
-        (1, 2)    -> (1, 3)
-        (1,)      -> (2,)
-        ()        -> ()
+
+    # Per-operator dispatch table
+    # API: Callable[[Version], Iterable[VersionBoundary]]
+    handler = {
+        ">": lambda v: [VersionBoundary(version=v, operator=">")],
+        ">=": lambda v: [VersionBoundary(version=v, operator=">=")],
+        "<=": lambda v: [VersionBoundary(version=v, operator="<=")],
+        "<": lambda v: [VersionBoundary(version=v, operator="<")],
+        "=": VersionBoundary.equal,
+        "~": VersionBoundary.tilde,
+        "^": VersionBoundary.caret,
+        None: VersionBoundary.equal,
+    }
+
+    for match in RANGE_SPECIFIER_SIMPLE.finditer(specifier_string):
+        operator, version_string = match.group("operator", "version")
+
+        for boundary in handler[operator](Version.parse(version_string)):
+            yield boundary
+
+
+def parse_range(range_string):
+    """Parse full NPM version range specification::
+
+        >>> empty = parse_range(""); list(map(str, empty))
+        []
+        >>> simple = parse_range("^1.0"); list(map(str, simple))
+        ['>=1.0', '<2']
+        >>> hyphen = parse_range("1.0 - 2.0"); list(map(str, hyphen))
+        ['>=1.0', '<2.1']
+
+    This method implements the ``range`` part of the grammar:
+    https://docs.npmjs.com/misc/semver#range-grammar.
+
+    Arguments:
+        range_string (str): The range specification to parse.
+
+    Returns:
+        Iterable[VersionBoundary]: Parsed boundaries.
+
+    Raises:
+        UnsupportedVersionToken: ``||`` is present in range_string.
     """
-    if len(v) == 3:
-        return (v[0], v[1], v[2] + 1)
-    if len(v) == 2:
-        return (v[0], v[1] + 1)
-    if len(v) == 1:
-        return (v[0] + 1,)
-    if len(v) == 0:
-        return ()
-
-def expand_hyphen_range(version):
+
+    HYPHEN = " - "
+
+    # FIXME: rpm should be able to process OR in dependencies
+    # This error reporting kept for backward compatibility
+    if "||" in range_string:
+        raise UnsupportedVersionToken(range_string)
+
+    if HYPHEN in range_string:
+        version_pair = map(Version.parse, range_string.split(HYPHEN, 2))
+        return VersionBoundary.hyphen(*version_pair)
+
+    elif range_string != "":
+        return parse_simple_seq(range_string)
+
+    else:
+        return []
+
+
+def unify_range(boundary_iter):
+    """Calculate largest allowed continuous version range from a set of boundaries::
+
+        >>> unify_range([])
+        ()
+        >>> _ = unify_range(parse_range("=1.2.3 <2")); tuple(map(str, _))
+        ('>=1.2.3', '<1.2.4')
+        >>> _ = unify_range(parse_range("~1.2 <1.2.5")); tuple(map(str, _))
+        ('>=1.2', '<1.2.5')
+
+    Arguments:
+        boundary_iter (Iterable[VersionBoundary]): The version boundaries to unify.
+
+    Returns:
+        (VersionBoundary, VersionBoundary):
+            Lower and upper boundary of the unified range.
     """
-    Converts a hyphen range into its equivalent comparator set.
-        '1.2.3 - 2.3.4' -> '>=1.2.3 <=2.3.4'
 
-    https://docs.npmjs.com/misc/semver#hyphen-ranges-xyz---abc
+    # Drop boundaries with empty version
+    boundary_iter = (
+        boundary for boundary in boundary_iter if not boundary.version.empty
+    )
+
+    # Split input sequence into upper/lower boundaries
+    lower_list, upper_list = [], []
+    for boundary in boundary_iter:
+        if boundary.lower:
+            lower_list.append(boundary)
+        elif boundary.upper:
+            upper_list.append(boundary)
+        else:
+            msg = "Unsupported boundary for unify_range: {0}".format(boundary)
+            raise ValueError(msg)
+
+    # Select maximum from lower boundaries and minimum from upper boundaries
+    intermediate = (
+        max(lower_list) if lower_list else None,
+        min(upper_list) if upper_list else None,
+    )
+
+    return tuple(filter(None, intermediate))
+
+
+def rpm_format(requirement, version_spec="*"):
+    """Format requirement as RPM boolean dependency::
+
+        >>> rpm_format("nodejs(engine)")
+        'nodejs(engine)'
+        >>> rpm_format("npm(foo)", ">=1.0.0")
+        'npm(foo) >= 1.0.0'
+        >>> rpm_format("npm(bar)", "~1.2")
+        '(npm(bar) >= 1.2 with npm(bar) < 1.3)'
+
+    Arguments:
+        requirement (str): The name of the requirement.
+        version_spec (str): The NPM version specification for the requirement.
+
+    Returns:
+        str: Formatted requirement.
     """
-    lower, upper = version.split(' - ', 1)
-    upper_parts = parse_version(upper)
-    if len(upper_parts) == 3:
-        return '>={} <={}'.format(lower, upper)
-    # Special behaviour if the upper bound is partial:
-    if len(upper_parts) == 2:
-        return '>={} <{}.{}'.format(lower, upper_parts[0], upper_parts[1] + 1)
-    if len(upper_parts) == 1:
-        return '>={} <{}'.format(lower, upper_parts[0] + 1)
-    if len(upper_parts) == 0:
-        return '>={}'.format(lower)
-
-def convert_dep(req, version):
+
+    TEMPLATE = "{name} {boundary.operator} {boundary.version!s}"
+
+    try:
+        boundary_tuple = unify_range(parse_range(version_spec))
+
+    except UnsupportedVersionToken:
+        # FIXME: Typos and print behavior kept for backward compatibility
+        warning_lines = [
+            "WARNING: The {requirement} dependency contains an OR (||) dependency: '{version_spec}.",
+            "Please manually include a versioned dependency in your spec file if necessary",
+        ]
+        warning = "\n".join(warning_lines).format(
+            requirement=requirement, version_spec=version_spec
+        )
+        print(warning, end="", file=sys.stderr)
+
+        return requirement
+
+    formatted = [
+        TEMPLATE.format(name=requirement, boundary=boundary)
+        for boundary in boundary_tuple
+    ]
+
+    if len(formatted) > 1:
+        return "({0})".format(" with ".join(formatted))
+    elif len(formatted) == 1:
+        return formatted[0]
+    else:
+        return requirement
+
+
+def has_only_bundled_dependencies(module_dir_path):
+    """Determines if the module contains only bundled dependencies.
+
+    Dependencies are considered un-bundled when they are symlinks
+    pointing outside the root module's tree.
+
+    Arguments:
+        module_dir_path (str):
+            Path to the module directory (directory with ``package.json``).
+
+    Returns:
+        bool: True if all dependencies are bundled, False otherwise.
     """
-    Converts an NPM requirement to an equivalent list of RPM requirements.
+
+    module_root_path = os.path.abspath(module_dir_path)
+    dependency_root_path = os.path.join(module_root_path, "node_modules")
+
+    try:
+        dependency_path_iter = (
+            os.path.join(dependency_root_path, basename)
+            for basename in os.listdir(dependency_root_path)
+        )
+        linked_dependency_iter = (
+            os.path.realpath(path)
+            for path in dependency_path_iter
+            if os.path.islink(path)
+        )
+        outside_dependency_iter = (
+            path
+            for path in linked_dependency_iter
+            if not path.startswith(module_root_path)
+        )
+
+        return not any(outside_dependency_iter)
+    except OSError:  # node_modules does not exist
+        return False
+
+
+def extract_dependencies(metadata_path, optional=False):
+    """Extract all dependencies in RPM format from package metadata.
+
+    Arguments:
+        metadata_path (str): Path to package metadata (``package.json``).
+        optional (bool):
+            If True, extract ``optionalDependencies``
+            instead of ``dependencies``.
+
+    Yields:
+        RPM-formatted dependencies.
+
+    Raises:
+        TypeError: Invalid dependency data type.
     """
-    # The version is a space-separated set of one or more comparators.
-    # There can be any number of comparators (even more than two) using all the
-    # various shortcut operators, but ultimately the comparator set is
-    # equivalent to a continuous range of version numbers, with an upper and
-    # lower bound (possibly inclusive or exclusive at each end).
-
-    # Start by defining the range as infinite.
-    lower_bound = ()
-    lower_bound_inclusive = True
-    upper_bound = ()
-    upper_bound_inclusive = False
-
-    # Helper function to narrow the lower bound to the given version, if it's
-    # *higher* than what we have now.
-    def narrow_lower(parts, inclusive):
-        nonlocal lower_bound, lower_bound_inclusive
-        if parts > lower_bound:
-            lower_bound = parts
-            lower_bound_inclusive = inclusive
-        elif parts == lower_bound:
-            if not inclusive and lower_bound_inclusive:
-                lower_bound_inclusive = False
-    # Same for the upper bound.
-    def narrow_upper(parts, inclusive):
-        nonlocal upper_bound, upper_bound_inclusive
-        if parts == ():
-            return
-        if upper_bound == () or parts < upper_bound:
-            upper_bound = parts
-            upper_bound_inclusive = inclusive
-        elif parts == upper_bound:
-            if not inclusive and upper_bound_inclusive:
-                upper_bound_inclusive = False
-
-    # For each comparator in the set, narrow the range to match it,
-    # using the two helper functions.
-    for operator, v in re.findall(r'(<=|>=|<|>|=|\^|~)?\s*(\S+)\s*', version):
-        if not operator:
-            operator = '='
-        parts = parse_version(v)
-
-        if operator == '>':
-            narrow_lower(parts, False)
-
-        elif operator == '>=':
-            narrow_lower(parts, True)
-
-        elif operator == '<':
-            narrow_upper(parts, False)
-
-        elif operator == '<=':
-            narrow_upper(parts, True)
-
-        elif operator == '=':
-            narrow_lower(parts, True)
-            narrow_upper(incremented(parts), False)
-
-        elif operator == '~':
-            narrow_lower(parts, True)
-            if len(parts) == 0:
-                pass
-            elif len(parts) == 1:
-                narrow_upper((parts[0] + 1,), False)
-            else:
-                narrow_upper((parts[0], parts[1] + 1), False)
-
-        elif operator == '^':
-            narrow_lower(parts, True)
-            if len(parts) == 0:
-                pass
-            elif len(parts) == 1:
-                narrow_upper((parts[0] + 1,), False)
-            elif len(parts) == 2:
-                if parts[0] == 0:
-                    narrow_upper((0, parts[1] + 1), False)
-                else:
-                    narrow_upper((parts[0] + 1,), False)
-            elif len(parts) == 3:
-                if parts[0] == 0 and parts[1] == 0:
-                    narrow_upper((0, 0, parts[2] + 1), False)
-                elif parts[0] == 0:
-                    narrow_upper((0, parts[1] + 1), False)
-                else:
-                    narrow_upper((parts[0] + 1,), False)
-
-    # At the end, we have an upper and lower bound which satisfies all the
-    # comparators in the set. This is what will become our RPM version
-    # requirements.
-
-    # Special case: no effective bounds.
-    if not lower_bound and not upper_bound:
-        return [req]
-
-    # Otherwise, produce RPM requirements for the upper and lower bounds.
-    deps = []
-    if lower_bound not in [(), (0,), (0, 0), (0, 0, 0)]:
-        deps.append('{} {} {}'.format(
-                req,
-                '>=' if lower_bound_inclusive else '>',
-                '.'.join(str(part) for part in lower_bound)))
-    if upper_bound != ():
-        deps.append('{} {} {}'.format(
-                req,
-                '<=' if upper_bound_inclusive else '<',
-                '.'.join(str(part) for part in upper_bound)))
-    return deps
-
-if __name__ == '__main__':
-    main()
+
+    if has_only_bundled_dependencies(os.path.dirname(metadata_path)):
+        return  # skip
+
+    # Read metadata
+    try:
+        with open(metadata_path, mode="r") as metadata_file:
+            metadata = json.load(metadata_file)
+    except OSError:  # Invalid metadata file
+        return  # skip
+
+    # Report required NodeJS version with required dependencies
+    if not optional:
+        try:
+            yield rpm_format("nodejs(engine)", metadata["engines"]["node"])
+        except KeyError:  # NodeJS engine version unspecified
+            yield rpm_format("nodejs(engine)")
+
+    # Report listed dependencies
+    kind = "optionalDependencies" if optional else "dependencies"
+    container = metadata.get(kind, {})
+
+    if isinstance(container, dict):
+        for name, version_spec in container.items():
+            yield rpm_format(REQUIREMENT_NAME_TEMPLATE.format(name=name), version_spec)
+
+    elif isinstance(container, list):
+        for name in container:
+            yield rpm_format(REQUIREMENT_NAME_TEMPLATE.format(name=name))
+
+    elif isinstance(container, str):
+        yield rpm_format(REQUIREMENT_NAME_TEMPLATE.format(name=name))
+
+    else:
+        raise TypeError("invalid package.json: dependencies not a valid type")
+
+
+if __name__ == "__main__":
+    nested = (
+        extract_dependencies(path.strip(), optional="--optional" in sys.argv)
+        for path in sys.stdin
+    )
+    flat = chain.from_iterable(nested)
+    # Ignore parentheses around the requirements when sorting
+    ordered = sorted(flat, key=lambda s: s.strip("()"))
+
+    print(*ordered, sep="\n")
diff --git a/SOURCES/nodejs_native.attr b/SOURCES/nodejs_native.attr
index 0527af6..91ea775 100644
--- a/SOURCES/nodejs_native.attr
+++ b/SOURCES/nodejs_native.attr
@@ -1,2 +1,2 @@
-%__nodejs_native_requires %{_rpmconfigdir}/nodejs_native.req
-%__nodejs_native_path ^/usr/lib.*/node_modules/.*\\.node$
+%__rh_nodejs10_native_requires %{_rpmconfigdir}/rh_nodejs10_native.req
+%__rh_nodejs10_native_path ^(/opt/rh/rh-nodejs10/root)?/usr/lib.*/node_modules/.*\\.node$
diff --git a/SPECS/rh-nodejs10.spec b/SPECS/rh-nodejs10.spec
index ae1e8dc..71eed01 100644
--- a/SPECS/rh-nodejs10.spec
+++ b/SPECS/rh-nodejs10.spec
@@ -1,19 +1,21 @@
 %global scl_name_prefix rh-
 %global scl_name_base nodejs
 %global scl_name_version 10
- 
+
 %global scl %{scl_name_prefix}%{scl_name_base}%{scl_name_version}
 
 %scl_package %scl
 %global install_scl 1
 
+%global rpm_magic_name %{lua: name, _ = string.gsub(rpm.expand("%{scl_name}"), "-", "_"); print(name)}
+
 # do not produce empty debuginfo package
 %global debug_package %{nil}
 
 Summary: %scl Software Collection
 Name: %scl_name
 Version: 3.2
-Release: 2%{?dist}
+Release: 3%{?dist}
 
 Source1: macros.nodejs
 Source2: nodejs.attr
@@ -58,7 +60,7 @@ Package shipping essential configuration macros to build %scl Software Collectio
 
 %package scldevel
 Summary: Package shipping development files for %scl
- 
+
 %description scldevel
 Package shipping development files, especially usefull for development of
 packages depending on %scl Software Collection.
@@ -101,12 +103,12 @@ EOF
 
 # install rpm magic
 install -Dpm0644 %{SOURCE1} %{buildroot}%{_root_sysconfdir}/rpm/macros.%{name}
-install -Dpm0644 %{SOURCE2} %{buildroot}%{_rpmconfigdir}/fileattrs/%{name}.attr
-install -pm0755 %{SOURCE3} %{buildroot}%{_rpmconfigdir}/%{name}.prov
-install -pm0755 %{SOURCE4} %{buildroot}%{_rpmconfigdir}/%{name}.req
+install -Dpm0644 %{SOURCE2} %{buildroot}%{_rpmconfigdir}/fileattrs/%{rpm_magic_name}.attr
+install -pm0755 %{SOURCE3} %{buildroot}%{_rpmconfigdir}/%{rpm_magic_name}.prov
+install -pm0755 %{SOURCE4} %{buildroot}%{_rpmconfigdir}/%{rpm_magic_name}.req
 install -pm0755 %{SOURCE5} %{buildroot}%{_rpmconfigdir}/%{name}-symlink-deps
 install -pm0755 %{SOURCE6} %{buildroot}%{_rpmconfigdir}/%{name}-fixdep
-install -Dpm0644 %{SOURCE7} %{buildroot}%{_rpmconfigdir}/fileattrs/%{name}_native.attr
+install -Dpm0644 %{SOURCE7} %{buildroot}%{_rpmconfigdir}/fileattrs/%{rpm_magic_name}_native.attr
 install -Dpm0644 %{SOURCE10} %{buildroot}%{_datadir}/node/multiver_modules
 install -pm0755 %{SOURCE11} %{buildroot}%{_rpmconfigdir}/%{name}-symlink-deps
 
@@ -117,12 +119,12 @@ EOF
 
 # ensure Requires are added to every native module that match the Provides from
 # the nodejs build in the buildroot
-cat << EOF > %{buildroot}%{_rpmconfigdir}/%{name}_native.req
+cat << EOF > %{buildroot}%{_rpmconfigdir}/%{rpm_magic_name}_native.req
 #!/bin/sh
 echo 'nodejs10-nodejs(abi) = %nodejs_abi'
 echo 'nodejs10-nodejs(v8-abi) = %v8_abi'
 EOF
-chmod 0755 %{buildroot}%{_rpmconfigdir}/%{name}_native.req
+chmod 0755 %{buildroot}%{_rpmconfigdir}/%{rpm_magic_name}_native.req
 
 cat << EOF > %{buildroot}%{_rpmconfigdir}/%{name}-require.sh
 #!/bin/sh
@@ -151,6 +153,19 @@ install -m 644 %{scl_name}.7 %{buildroot}%{_mandir}/man7/%{scl_name}.7
 mkdir -p %{buildroot}%{_datadir}/licenses/
 
 
+%check
+# Assert that installed file attributes are named appropriately
+for attr_file in %{buildroot}%{_rpmconfigdir}/fileattrs/*.attr; do
+    macro_name="$(basename "$attr_file"|sed -E 's|\.attr$||')"
+
+    # Delete comments and empty lines
+    # Select all remaining lines that start unexpectedly
+    # Fail if any such line is found, cotinue otherwise
+    sed -E -e '/^$/d;/^#/d' "$attr_file" \
+    | grep -E -v "^%%__${macro_name}_" \
+    && exit 1 || :
+done
+
 %files
 
 %files -f filesystem runtime
@@ -169,13 +184,17 @@ mkdir -p %{buildroot}%{_datadir}/licenses/
 %files build
 %{_root_sysconfdir}/rpm/macros.%{scl}-config
 %{_root_sysconfdir}/rpm/macros.%{name}
-%{_rpmconfigdir}/fileattrs/%{name}*.attr
+%{_rpmconfigdir}/fileattrs/%{rpm_magic_name}*.attr
 %{_rpmconfigdir}/%{name}*
+%{_rpmconfigdir}/%{rpm_magic_name}*
 
 %files scldevel
 %{_root_sysconfdir}/rpm/macros.%{scl_name_base}-scldevel
 
 %changelog
+* Fri Sep 06 2019 Jan Staněk <jstanek@redhat.com> - 3.2-3
+- Import enhanced Provides/Requires generators from Fedora
+
 * Thu Sep 20 2018 Zuzana Svetlikova <zsvetlik@redhat.com> - 3.2-2
 - Resolves: RHBZ#1584252
 - update to fedora packaging, generate bundled provides automaticaly
@@ -244,7 +263,7 @@ mkdir -p %{buildroot}%{_datadir}/licenses/
 - Own python modules directory
 
 * Wed Oct 08 2014 Tomas Hrcka <thrcka@redhat.com> - 1.2-29
-- Require scriptlet scl_devel from root_bindir not scl_bindir 
+- Require scriptlet scl_devel from root_bindir not scl_bindir
 
 * Mon Oct 06 2014 Tomas Hrcka <thrcka@redhat.com> - 1.2-28
 - bump scl version
@@ -261,10 +280,10 @@ mkdir -p %{buildroot}%{_datadir}/licenses/
   Related: #1061452
 
 * Mon Feb 17 2014 Tomas Hrcka <thrcka@redhat.com> - 1.1-24
-- Require version of scl-utils 
+- Require version of scl-utils
 
 * Wed Feb 12 2014 Tomas Hrcka <thrcka@redhat.com> - 1.1-23
-- Define scl_name_base and scl_name_version macros 
+- Define scl_name_base and scl_name_version macros
 
 * Wed Feb 12 2014 Honza Horak <hhorak@redhat.com> - 1.1-22
 - Some more grammar fixes in README
@@ -287,7 +306,7 @@ mkdir -p %{buildroot}%{_datadir}/licenses/
 - clean up after previous fix
 
 * Fri Aug 09 2013 thrcka@redhat.com - 1-16
-- RHBZ#993425 - nodejs010.req fails when !noarch 
+- RHBZ#993425 - nodejs010.req fails when !noarch
 
 * Mon Jun 03 2013 thrcka@redhat.com - 1-15
 - Changed licence to MIT