diff --git a/meta/classes-global/insane.bbclass b/meta/classes-global/insane.bbclass
index c1d8b16586..c0868849fb 100644
--- a/meta/classes-global/insane.bbclass
+++ b/meta/classes-global/insane.bbclass
@@ -990,8 +990,8 @@ def package_qa_check_unlisted_pkg_lics(package, d):
     if not pkg_lics:
         return
 
-    recipe_lics_set = oe.license.list_licenses(d.getVar('LICENSE'))
-    package_lics = oe.license.list_licenses(pkg_lics)
+    recipe_lics_set = oe.license.list_licenses(d.getVar('LICENSE'), d)
+    package_lics = oe.license.list_licenses(pkg_lics, d)
     unlisted = package_lics - recipe_lics_set
     if unlisted:
         oe.qa.handle_error("unlisted-pkg-lics",
@@ -1113,7 +1113,7 @@ python do_package_qa () {
     import oe.packagedata
 
     # Check for obsolete license references in main LICENSE (packages are checked below for any changes)
-    main_licenses = oe.license.list_licenses(d.getVar('LICENSE'))
+    main_licenses = oe.license.list_licenses(d.getVar('LICENSE'), d)
     obsolete = set(oe.license.obsolete_license_list()) & main_licenses
     if obsolete:
         oe.qa.handle_error("obsolete-license", "Recipe LICENSE includes obsolete licenses %s" % ' '.join(obsolete), d)
@@ -1332,7 +1332,7 @@ python do_qa_patch() {
 
     # Detect cargo-based tests
     elif os.path.exists(os.path.join(srcdir, "Cargo.toml")) and (
-        match_line_in_files(srcdir, "**/*.rs", r'\s*#\s*\[\s*test\s*\]') or 
+        match_line_in_files(srcdir, "**/*.rs", r'\s*#\s*\[\s*test\s*\]') or
         match_line_in_files(srcdir, "**/*.rs", r'\s*#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]')
     ):
         oe.qa.handle_error("unimplemented-ptest", "%s: cargo-based tests detected" % d.getVar('PN'), d)
diff --git a/meta/classes-global/license.bbclass b/meta/classes-global/license.bbclass
index 2d4ad6df26..86c9ee5878 100644
--- a/meta/classes-global/license.bbclass
+++ b/meta/classes-global/license.bbclass
@@ -52,10 +52,12 @@ python perform_packagecopy:prepend () {
 perform_packagecopy[vardeps] += "LICENSE_CREATE_PACKAGE"
 
 def get_recipe_info(d):
+    import oe.license
+
     info = {}
     info["PV"] = d.getVar("PV")
     info["PR"] = d.getVar("PR")
-    info["LICENSE"] = d.getVar("LICENSE")
+    info["LICENSE"] = oe.license.convert_legacy_license_to_spdx(d, d.getVar("LICENSE"))
     return info
 
 def add_package_and_files(d):
@@ -116,6 +118,7 @@ def find_license_files(d):
     """
     import shutil
     import oe.license
+    import oe.spdx_license
     from collections import defaultdict, OrderedDict
 
     # All the license files for the package
@@ -128,86 +131,18 @@ def find_license_files(d):
     # List of basename, path tuples
     lic_files_paths = []
     # hash for keep track generic lics mappings
-    non_generic_lics = {}
+    non_generic_lics = set()
     # Entries from LIC_FILES_CHKSUM
     lic_chksums = {}
-    license_source_dirs = []
-    license_source_dirs.append(generic_directory)
-    try:
-        additional_lic_dirs = d.getVar('LICENSE_PATH').split()
-        for lic_dir in additional_lic_dirs:
-            license_source_dirs.append(lic_dir)
-    except:
-        pass
-
-    class FindVisitor(oe.license.LicenseVisitor):
-        def visit_Str(self, node):
-            #
-            # Until I figure out what to do with
-            # the two modifiers I support (or greater = +
-            # and "with exceptions" being *
-            # we'll just strip out the modifier and put
-            # the base license.
-            find_licenses(node.s.replace("+", "").replace("*", ""))
-            self.generic_visit(node)
-
-        def visit_Constant(self, node):
-            find_licenses(node.value.replace("+", "").replace("*", ""))
-            self.generic_visit(node)
-
-    def find_licenses(license_type):
-        try:
-            bb.utils.mkdirhier(gen_lic_dest)
-        except:
-            pass
-        spdx_generic = None
-        license_source = None
-        # If the generic does not exist we need to check to see if there is an SPDX mapping to it,
-        # unless NO_GENERIC_LICENSE is set.
-        for lic_dir in license_source_dirs:
-            if not os.path.isfile(os.path.join(lic_dir, license_type)):
-                if d.getVarFlag('SPDXLICENSEMAP', license_type) != None:
-                    # Great, there is an SPDXLICENSEMAP. We can copy!
-                    bb.debug(1, "We need to use a SPDXLICENSEMAP for %s" % (license_type))
-                    spdx_generic = d.getVarFlag('SPDXLICENSEMAP', license_type)
-                    license_source = lic_dir
-                    break
-            elif os.path.isfile(os.path.join(lic_dir, license_type)):
-                spdx_generic = license_type
-                license_source = lic_dir
-                break
-
-        non_generic_lic = d.getVarFlag('NO_GENERIC_LICENSE', license_type)
-        if spdx_generic and license_source:
-            # we really should copy to generic_ + spdx_generic, however, that ends up messing the manifest
-            # audit up. This should be fixed in emit_pkgdata (or, we actually got and fix all the recipes)
-
-            lic_files_paths.append(("generic_" + license_type, os.path.join(license_source, spdx_generic),
-                                    None, None))
-
-            # The user may attempt to use NO_GENERIC_LICENSE for a generic license which doesn't make sense
-            # and should not be allowed, warn the user in this case.
-            if d.getVarFlag('NO_GENERIC_LICENSE', license_type):
-                oe.qa.handle_error("license-no-generic",
-                    "%s: %s is a generic license, please don't use NO_GENERIC_LICENSE for it." % (pn, license_type), d)
-
-        elif non_generic_lic and non_generic_lic in lic_chksums:
-            # if NO_GENERIC_LICENSE is set, we copy the license files from the fetched source
-            # of the package rather than the license_source_dirs.
-            lic_files_paths.append(("generic_" + license_type,
-                                    os.path.join(srcdir, non_generic_lic), None, None))
-            non_generic_lics[non_generic_lic] = license_type
-        else:
-            # Explicitly avoid the CLOSED license because this isn't generic
-            if license_type != 'CLOSED':
-                # And here is where we warn people that their licenses are lousy
-                oe.qa.handle_error("license-exists",
-                    "%s: No generic license file exists for: %s in any provider" % (pn, license_type), d)
-            pass
 
     if not generic_directory:
         bb.fatal("COMMON_LICENSE_DIR is unset. Please set this in your distro config")
 
+    try:
+        bb.utils.mkdirhier(gen_lic_dest)
+    except:
+        pass
+
     for url in lic_files.split():
         try:
             (method, host, path, user, pswd, parm) = bb.fetch.decodeurl(url)
@@ -221,14 +156,28 @@ def find_license_files(d):
         endline = parm.get('endline')
         lic_chksums[path] = (chksum, beginline, endline)
 
-    v = FindVisitor()
+    def walk_license(node):
+        if isinstance(node, oe.spdx_license.Identifier):
+            p, non_generic_fn = oe.license.get_license_path(d, node)
+            if p is not None and (not non_generic_fn or non_generic_fn in lic_chksums):
+                lic_files_paths.append(("generic_" + node.ident, p, None, None))
+                if non_generic_fn:
+                    non_generic_lics.add(non_generic_fn)
+            else:
+                oe.qa.handle_error("license-exists",
+                    "%s: No generic license file exists for: %s in any provider" % (pn, node.ident), d)
+
+        for child in node.children:
+            walk_license(child)
+
     try:
-        v.visit_string(d.getVar('LICENSE'))
-    except oe.license.InvalidLicense as exc:
-        bb.fatal('%s: %s' % (d.getVar('PF'), exc))
-    except SyntaxError:
-        oe.qa.handle_error("license-syntax",
-            "%s: Failed to parse LICENSE: %s" % (d.getVar('PF'), d.getVar('LICENSE')), d)
+        licensestr = d.getVar("LICENSE")
+        if licensestr and licensestr != "CLOSED":
+            node = oe.license.parse_legacy_license(d, licensestr)
+            walk_license(node)
+    except oe.spdx_license.ParseError as e:
+        oe.qa.handle_error("license-syntax", e.format(prefix=f"{d.getVar('PF')}: "), d)
+
     # Add files from LIC_FILES_CHKSUM to list of license files
     lic_chksum_paths = defaultdict(OrderedDict)
     for path, data in sorted(lic_chksums.items()):
diff --git a/meta/classes-recipe/license_image.bbclass b/meta/classes-recipe/license_image.bbclass
index 1ecd8f88bc..258082c82a 100644
--- a/meta/classes-recipe/license_image.bbclass
+++ b/meta/classes-recipe/license_image.bbclass
@@ -131,10 +131,10 @@ def write_license_files(d, license_manifest, pkg_dic, rootfs):
                     oe.qa.handle_error('license-exception', "Including %s with incompatible license(s) %s into the image, because it has been allowed by exception list." %(pkg, ' '.join(incompatible_licenses)), d)
             try:
                 (pkg_dic[pkg]["LICENSE"], pkg_dic[pkg]["LICENSES"]) = \
-                    oe.license.manifest_licenses(pkg_dic[pkg]["LICENSE"],
-                    remaining_bad_licenses, oe.license.canonical_license, d)
+                    oe.license.manifest_licenses(d, pkg_dic[pkg]["LICENSE"],
+                    remaining_bad_licenses)
             except oe.license.LicenseError as exc:
-                bb.fatal('%s: %s' % (d.getVar('P'), exc))
+                bb.fatal(exc.format(prefix=d.getVar("P") + ": "))
 
             if not "IMAGE_MANIFEST" in pkg_dic[pkg]:
                 # Rootfs manifest
diff --git a/meta/classes/buildhistory.bbclass b/meta/classes/buildhistory.bbclass
index 8f605a720e..ce6ef8971a 100644
--- a/meta/classes/buildhistory.bbclass
+++ b/meta/classes/buildhistory.bbclass
@@ -82,6 +82,7 @@ python buildhistory_emit_pkghistory() {
     import shlex
     import errno
     import shutil
+    import oe.license
 
     if not "package" in (d.getVar('BUILDHISTORY_FEATURES') or "").split():
         return 0
@@ -199,7 +200,7 @@ python buildhistory_emit_pkghistory() {
     pv = d.getVar('PV')
     pr = d.getVar('PR')
     layer = bb.utils.get_file_layer(d.getVar('FILE'), d)
-    license = d.getVar('LICENSE')
+    license = oe.license.convert_legacy_license_to_spdx(d, d.getVar('LICENSE'))
 
     pkgdata_dir = d.getVar('PKGDATA_DIR')
     packages = ""
diff --git a/meta/classes/copyleft_filter.bbclass b/meta/classes/copyleft_filter.bbclass
index 83cd90060d..89bb779ebc 100644
--- a/meta/classes/copyleft_filter.bbclass
+++ b/meta/classes/copyleft_filter.bbclass
@@ -61,9 +61,9 @@ def copyleft_should_include(d):
         exclude = oe.data.typed_value('COPYLEFT_LICENSE_EXCLUDE', d)
 
         try:
-            is_included, reason = oe.license.is_included(d.getVar('LICENSE'), include, exclude)
+            is_included, reason = oe.license.is_included(d, d.getVar('LICENSE'), include, exclude)
         except oe.license.LicenseError as exc:
-            bb.fatal('%s: %s' % (d.getVar('PF'), exc))
+            bb.fatal(exc.format(prefix=d.getVar("PF") + ": "))
         else:
             if is_included:
                 if reason:
diff --git a/meta/conf/licenses.conf b/meta/conf/licenses.conf
index dcb499c3f3..c639477b72 100644
--- a/meta/conf/licenses.conf
+++ b/meta/conf/licenses.conf
@@ -114,6 +114,10 @@ SPDXLICENSEMAP[vim] = "Vim"
 # Silicon Graphics variations
 SPDXLICENSEMAP[SGIv1] = "SGI-OpenGL"
 
+SPDXLICENSEMAP[Apache-2.0-with-LLVM-exception] = "Apache-2.0 WITH LLVM-exception"
+SPDXLICENSEMAP[GPL-3-with-bison-exception] = "GPL-3.0-or-later WITH Bison-exception-2.2"
+SPDXLICENSEMAP[GPL-2.0-with-Linux-syscall-note] = "GPL-2.0-only WITH Linux-syscall-note"
+
 # Additional license directories. Add your custom licenses directories this path.
 # LICENSE_PATH += "${COREBASE}/custom-licenses"
 
diff --git a/meta/lib/oe/license.py b/meta/lib/oe/license.py
index bd28a247c9..9e59d82f8d 100644
--- a/meta/lib/oe/license.py
+++ b/meta/lib/oe/license.py
@@ -9,6 +9,7 @@ import ast
 import re
 import oe.qa
 from fnmatch import fnmatchcase as fnmatch
+import oe.spdx_license
 
 def license_ok(license, dont_want_licenses):
     """ Return False if License exist in dont_want_licenses else True """
@@ -27,89 +28,34 @@ def obsolete_license_list():
             "Artistic-1", "AFL-2", "AFL-1", "AFLv2", "AFLv1", "CDDLv1", "CDDL-1", "EPLv1.0", "FreeType", "Nauman",
             "tcl", "vim", "SGIv1"]
 
-class LicenseError(Exception):
-    pass
-
-class LicenseSyntaxError(LicenseError):
-    def __init__(self, licensestr, exc):
-        self.licensestr = licensestr
-        self.exc = exc
-        LicenseError.__init__(self)
-
-    def __str__(self):
-        return "error in '%s': %s" % (self.licensestr, self.exc)
-
-class InvalidLicense(LicenseError):
-    def __init__(self, license):
-        self.license = license
-        LicenseError.__init__(self)
-
-    def __str__(self):
-        return "invalid characters in license '%s'" % self.license
-
-license_operator_chars = '&|() '
-license_operator = re.compile(r'([' + license_operator_chars + '])')
-license_pattern = re.compile(r'[a-zA-Z0-9.+_\-]+$')
-
-class LicenseVisitor(ast.NodeVisitor):
-    """Get elements based on OpenEmbedded license strings"""
-    def get_elements(self, licensestr):
-        new_elements = []
-        elements = list([x for x in license_operator.split(licensestr) if x.strip()])
-        for pos, element in enumerate(elements):
-            if license_pattern.match(element):
-                if pos > 0 and license_pattern.match(elements[pos-1]):
-                    new_elements.append('&')
-                element = '"' + element + '"'
-            elif not license_operator.match(element):
-                raise InvalidLicense(element)
-            new_elements.append(element)
-
-        return new_elements
-
-    """Syntax tree visitor which can accept elements previously generated with
-    OpenEmbedded license string"""
-    def visit_elements(self, elements):
-        self.visit(ast.parse(' '.join(elements)))
-
-    """Syntax tree visitor which can accept OpenEmbedded license strings"""
-    def visit_string(self, licensestr):
-        self.visit_elements(self.get_elements(licensestr))
-
-class FlattenVisitor(LicenseVisitor):
-    """Flatten a license tree (parsed from a string) by selecting one of each
-    set of OR options, in the way the user specifies"""
-    def __init__(self, choose_licenses):
-        self.choose_licenses = choose_licenses
-        self.licenses = []
-        LicenseVisitor.__init__(self)
-
-    def visit_Constant(self, node):
-        self.licenses.append(node.value)
-
-    def visit_BinOp(self, node):
-        if isinstance(node.op, ast.BitOr):
-            left = FlattenVisitor(self.choose_licenses)
-            left.visit(node.left)
-
-            right = FlattenVisitor(self.choose_licenses)
-            right.visit(node.right)
-
-            selected = self.choose_licenses(left.licenses, right.licenses)
-            self.licenses.extend(selected)
-        else:
-            self.generic_visit(node)
 
-def flattened_licenses(licensestr, choose_licenses):
+LicenseError = oe.spdx_license.ParseError
+
+def flattened_licenses(d, licensestr, choose_licenses):
     """Given a license string and choose_licenses function, return a flat list of licenses"""
-    flatten = FlattenVisitor(choose_licenses)
-    try:
-        flatten.visit_string(licensestr)
-    except SyntaxError as exc:
-        raise LicenseSyntaxError(licensestr, exc)
-    return flatten.licenses
+    def walk_license(node):
+        if isinstance(node, oe.spdx_license.Identifier):
+            return [node.ident]
+
+        if isinstance(node, oe.spdx_license.OrOp):
+            left = walk_license(node.left)
+            right = walk_license(node.right)
+
+            return choose_licenses(left, right)
+
+        ret = []
+        for child in node.children:
+            ret.extend(walk_license(child))
+
+        return ret
+
+    n = parse_legacy_license(d, licensestr)
+    if n is None:
+        return []
+
+    return walk_license(n)
 
-def is_included(licensestr, include_licenses=None, exclude_licenses=None):
+def is_included(d, licensestr, include_licenses=None, exclude_licenses=None):
     """Given a license string, a list of licenses to include and a list of
     licenses to exclude, determine if the license string matches the include
     list and does not match the exclude list.
@@ -147,7 +93,7 @@ def is_included(licensestr, include_licenses=None, exclude_licenses=None):
     if not exclude_licenses:
         exclude_licenses = []
 
-    licenses = flattened_licenses(licensestr, choose_licenses)
+    licenses = flattened_licenses(d, licensestr, choose_licenses)
     excluded = [lic for lic in licenses if exclude_license(lic)]
     included = [lic for lic in licenses if include_license(lic)]
     if excluded:
@@ -155,100 +101,76 @@ def is_included(licensestr, include_licenses=None, exclude_licenses=None):
     else:
         return True, included
 
-class ManifestVisitor(LicenseVisitor):
-    """Walk license tree (parsed from a string) removing the incompatible
-    licenses specified"""
-    def __init__(self, dont_want_licenses, canonical_license, d):
-        self._dont_want_licenses = dont_want_licenses
-        self._canonical_license = canonical_license
-        self._d = d
-        self._operators = []
-
-        self.licenses = []
-        self.licensestr = ''
-
-        LicenseVisitor.__init__(self)
-
-    def visit(self, node):
-        if isinstance(node, ast.Constant):
-            lic = node.value
-
-            if license_ok(self._canonical_license(self._d, lic),
-                    self._dont_want_licenses) == True:
-                if self._operators:
-                    ops = []
-                    for op in self._operators:
-                        if op == '[':
-                            ops.append(op)
-                        elif op == ']':
-                            ops.append(op)
-                        else:
-                            if not ops:
-                                ops.append(op)
-                            elif ops[-1] in ['[', ']']:
-                                ops.append(op)
-                            else:
-                                ops[-1] = op 
-
-                    for op in ops:
-                        if op == '[' or op == ']':
-                            self.licensestr += op
-                        elif self.licenses:
-                            self.licensestr += ' ' + op + ' '
-
-                    self._operators = []
-
-                self.licensestr += lic
-                self.licenses.append(lic)
-        elif isinstance(node, ast.BitAnd):
-            self._operators.append("&")
-        elif isinstance(node, ast.BitOr):
-            self._operators.append("|")
-        elif isinstance(node, ast.List):
-            self._operators.append("[")
-        elif isinstance(node, ast.Load):
-            self.licensestr += "]"
-
-        self.generic_visit(node)
-
-def manifest_licenses(licensestr, dont_want_licenses, canonical_license, d):
+def manifest_licenses(d, licensestr, dont_want_licenses):
     """Given a license string and dont_want_licenses list,
        return license string filtered and a list of licenses"""
-    manifest = ManifestVisitor(dont_want_licenses, canonical_license, d)
 
-    try:
-        elements = manifest.get_elements(licensestr)
+    node = parse_legacy_license(d, licensestr)
+    licenses = []
+
+    def walk_license(node):
+        nonlocal licenses
+        if isinstance(node, oe.spdx_license.Identifier):
+            if license_ok(node.ident, dont_want_licenses):
+                licenses.append(node.ident)
+                return node
+
+            return None
+
+        old_children = node.children
+        node.children = []
 
-        # Replace '()' to '[]' for handle in ast as List and Load types.
-        elements = ['[' if e == '(' else e for e in elements]
-        elements = [']' if e == ')' else e for e in elements]
+        for child in old_children:
+            node.children.append(walk_license(child))
 
-        manifest.visit_elements(elements)
-    except SyntaxError as exc:
-        raise LicenseSyntaxError(licensestr, exc)
+        # If all children are removed, remove this node also
+        if not any(node.children):
+            return None
 
-    # Replace '[]' to '()' for output correct license.
-    manifest.licensestr = manifest.licensestr.replace('[', '(').replace(']', ')')
+        # Elide a binary operation if it now only has one child
+        if isinstance(node, (oe.spdx_license.AndOp, oe.spdx_license.OrOp)):
+            if node.left is None:
+                return node.right
+            if node.right is None:
+                return node.left
 
-    return (manifest.licensestr, manifest.licenses)
+        # The left side (the license side) of a WITH can be removed, but not
+        # the right (the exception)
+        if isinstance(node, oe.spdx_license.WithOp):
+            if node.license is None:
+                return None
 
-class ListVisitor(LicenseVisitor):
-    """Record all different licenses found in the license string"""
-    def __init__(self):
-        self.licenses = set()
+        # If one of the above cases doesn't catch everything, raise an error
+        if not all(node.children):
+            node.children = old_children
+            pn = d.getVar("PN")
+            bb.fatal(f"{pn}: Removing licenses from {node.to_string} results in an invalid license expression")
 
-    def visit_Constant(self, node):
-        self.licenses.add(node.value)
+        return node
 
-def list_licenses(licensestr):
-    """Simply get a list of all licenses mentioned in a license string.
+    if node:
+        node = walk_license(node)
+
+    if not node:
+        return ("", licenses)
+
+    return (node.to_string(), licenses)
+
+def list_licenses(licensestr, d):
+    """Simply get a set of all licenses mentioned in a license string.
        Binary operators are not applied or taken into account in any way"""
-    visitor = ListVisitor()
-    try:
-        visitor.visit_string(licensestr)
-    except SyntaxError as exc:
-        raise LicenseSyntaxError(licensestr, exc)
-    return visitor.licenses
+    licenses = []
+
+    def walk_license(node):
+        nonlocal licenses
+        if isinstance(node, oe.spdx_license.Identifier):
+            licenses.append(node.ident)
+
+        for child in node.children:
+            walk_license(child)
+
+    walk_license(parse_legacy_license(d, licensestr))
+    return set(licenses)
 
 def apply_pkg_license_exception(pkg, bad_licenses, exceptions):
     """Return remaining bad licenses after removing any package exceptions"""
@@ -293,31 +215,38 @@ def expand_wildcard_licenses(d, wildcard_licenses):
 
     return list(licenses)
 
-def incompatible_license_contains(license, truevalue, falsevalue, d):
+def incompatible_license_contains(d, license, truevalue, falsevalue):
     license = canonical_license(d, license)
     bad_licenses = (d.getVar('INCOMPATIBLE_LICENSE') or "").split()
     bad_licenses = expand_wildcard_licenses(d, bad_licenses)
     return truevalue if license in bad_licenses else falsevalue
 
 def incompatible_pkg_license(d, dont_want_licenses, license):
-    # Handles an "or" or two license sets provided by
-    # flattened_licenses(), pick one that works if possible.
-    def choose_lic_set(a, b):
-        return a if all(license_ok(canonical_license(d, lic),
-                            dont_want_licenses) for lic in a) else b
+    def walk_license(node):
+        if isinstance(node, oe.spdx_license.Identifier):
+            if license_ok(node.ident, dont_want_licenses):
+                return []
+            return [node.ident]
+
+        incompatible_lic = []
+        for child in node.children:
+            c = walk_license(child)
+            if not c and isinstance(node, oe.spdx_license.OrOp):
+                return []
+            incompatible_lic.extend(c)
+
+        return incompatible_lic
 
     try:
-        licenses = flattened_licenses(license, choose_lic_set)
-    except LicenseError as exc:
-        bb.fatal('%s: %s' % (d.getVar('P'), exc))
+        node = parse_legacy_license(d, license)
+    except oe.spdx_license.ParseError as e:
+        bb.fatal(e.format(prefix=d.getVar('P') + ":"))
+        return []
 
-    incompatible_lic = []
-    for l in licenses:
-        license = canonical_license(d, l)
-        if not license_ok(license, dont_want_licenses):
-            incompatible_lic.append(license)
+    if not node:
+        return []
 
-    return sorted(incompatible_lic)
+    return sorted(walk_license(node))
 
 def incompatible_license(d, dont_want_licenses, package=None):
     """
@@ -394,6 +323,92 @@ def check_license_flags(d):
             return unmatched_flags
     return None
 
+
+def _substitute_legacy_license(licensestr):
+    if not licensestr or licensestr == "CLOSED":
+        return None
+
+    licensestr = licensestr.replace("&", " AND ").replace("|", " OR ")
+
+    return licensestr
+
+def _parse_legacy_license(d, licensestr):
+    node = oe.spdx_license.parse(licensestr, allow_unknown=True)
+
+    unknown_found = False
+
+    def convert_unknown(node):
+        nonlocal unknown_found
+
+        if isinstance(node, oe.spdx_license.UnknownId):
+            if d is not None:
+                v = d.getVarFlag("SPDXLICENSEMAP", node.ident)
+                if v:
+                    return oe.spdx_license.parse(v)
+
+            if node.ident == "Unknown":
+                return node
+
+            ident = re.sub(r'[^a-zA-Z0-9\.\-]', '-', node.ident)
+            unknown_found = True
+            return oe.spdx_license.LicenseRef("LicenseRef-" + ident, node.ident, token=node.token)
+
+        node.children = [convert_unknown(child) for child in node.children]
+        return node
+
+    return convert_unknown(node), unknown_found
+
+def parse_legacy_license(d, licensestr):
+    """
+    Converts a legacy license string to a SPDX license string and parses it,
+    returning a SPDX license abstract syntax tree
+
+    This can be removed and replaced with oe.spdx_license.parse(licensestr)
+    once legacy licenses are not longer allowed.
+
+    Unknown license IDs in the string are automatically converted to
+    LicenseRefs, assuming that they are provided as either a common license
+    file or a NO_GENERIC_LICENSE
+    """
+    licensestr = _substitute_legacy_license(licensestr)
+    if licensestr is None:
+        return None
+
+    node, _ = _parse_legacy_license(d, licensestr)
+    return node
+
+def convert_legacy_license_to_spdx(d, licensestr):
+    """
+    Converts a legacy license string to an SPDX-compatible license string
+    """
+    s = parse_legacy_license(d, licensestr)
+    if not s:
+        return licensestr
+
+    return s.to_string()
+
+def get_license_path(d, node):
+    """
+    Given an node from an SPDX AST, return the path to the license file
+    """
+    if isinstance(node, oe.spdx_license.LicenseRef):
+        filename = d.getVarFlag("NO_GENERIC_LICENSE", node.name)
+        if filename:
+            return d.expand("${S}/" + filename), filename
+
+    for lic_dir in [d.getVar("COMMON_LICENSE_DIR")] + (d.getVar("LICENSE_PATH") or "").split():
+        p = os.path.join(lic_dir, node.name)
+        if os.path.isfile(p):
+            return p, None
+
+        # Some license expressions already contain the "LicenseRef-" prefix, so
+        # look for a matching license name
+        p = os.path.join(lic_dir, node.ident)
+        if os.path.isfile(p):
+            return p, None
+
+    return None, None
+
 def check_license_format(d):
     """
     This function checks if LICENSE is well defined,
@@ -401,22 +416,75 @@ def check_license_format(d):
         No spaces are allowed between LICENSES.
     """
     pn = d.getVar('PN')
-    licenses = d.getVar('LICENSE')
-
-    elements = list(filter(lambda x: x.strip(), license_operator.split(licenses)))
-    for pos, element in enumerate(elements):
-        if license_pattern.match(element):
-            if pos > 0 and license_pattern.match(elements[pos - 1]):
-                oe.qa.handle_error('license-format',
-                        '%s: LICENSE value "%s" has an invalid format - license names ' \
-                        'must be separated by the following characters to indicate ' \
-                        'the license selection: %s' %
-                        (pn, licenses, license_operator_chars), d)
-        elif not license_operator.match(element):
-            oe.qa.handle_error('license-format',
-                    '%s: LICENSE value "%s" has an invalid separator "%s" that is not ' \
-                    'in the valid list of separators (%s)' %
-                    (pn, licenses, element, license_operator_chars), d)
+    packages = d.getVar("PACKAGES").split()
+
+    for v in ["LICENSE"] + [f"LICENSE:{p}" for p in packages]:
+        licenses = d.getVar(v)
+
+        if licenses == "INVALID":
+            continue
+
+        if licenses == "CLOSED":
+            bb.warn(f'{pn}: {v} is using "CLOSED", which is deprecated. Convert to using a license ref pointing to an actual license file, e.g.\n{v} = "LicenseRef-{pn}-CLOSED"')
+            continue
+
+        if not licenses:
+            continue
+
+        try:
+            spdx_license = _substitute_legacy_license(licenses)
+
+            s, unknown_found = _parse_legacy_license(d, spdx_license)
+            if not s:
+                continue
+
+            if spdx_license != licenses or unknown_found:
+                bb.warn(f'{pn}: {v} is using an old syntax and should be upgraded to: "{s.sort().to_string()}"')
+
+            if s:
+                # Check license refs
+                missing_generic_refs = set()
+                missing_refs = set()
+                deprecated = set()
+                def walk_license(node):
+                    nonlocal missing_generic_refs
+                    nonlocal missing_refs
+
+                    if isinstance(node, oe.spdx_license.LicenseRef):
+                        p, non_generic_fn = get_license_path(d, node)
+                        if not p:
+                            if non_generic_fn:
+                                missing_refs.add(node.ident)
+                            else:
+                                missing_generic_refs.add(node.ident)
+
+                    if isinstance(node, (oe.spdx_license.LicenseId, oe.spdx_license.ExceptionId)):
+                        if node.deprecated:
+                            deprecated.add(node.ident)
+
+                    for child in node.children:
+                        walk_license(child)
+
+                walk_license(s)
+                if missing_refs:
+                    missing_refs = ', '.join(sorted(missing_refs))
+                    oe.qa.handle_error("license-format",
+                                        f'{pn}: LICENSE contains a reference to license(s) {missing_refs} which are not defined in NO_GENERIC_LICENSE',
+                                        d)
+
+                if missing_generic_refs:
+                    missing_generic_refs = ', '.join(sorted(missing_generic_refs))
+                    oe.qa.handle_error("license-format",
+                                        f'{pn}: LICENSE contains a reference to license(s) {missing_generic_refs} for which no generic license was found',
+                                        d)
+
+                if deprecated:
+                    deprecated = ", ".join(sorted(deprecated))
+                    bb.warn(f'{pn}: {v} contains deprecated licenses {deprecated}')
+
+        except oe.spdx_license.ParseError as e:
+            oe.qa.handle_error("license-format",
+                            f'{pn}: {v} value has an invalid format:\n{e.format()}\n', d)
 
 def skip_incompatible_package_licenses(d, pkgs):
     if not pkgs:
@@ -461,13 +529,11 @@ def tidy_licenses(value):
     """
     Flat, split and sort licenses.
     """
-    from oe.license import flattened_licenses
-
     def _choose(a, b):
-        str_a, str_b  = sorted((" & ".join(a), " & ".join(b)), key=str.casefold)
-        return ["(%s | %s)" % (str_a, str_b)]
+        str_a, str_b  = sorted((" AND ".join(a), " AND ".join(b)), key=str.casefold)
+        return ["(%s OR %s)" % (str_a, str_b)]
 
     if not isinstance(value, str):
-        value = " & ".join(value)
+        value = " AND ".join(value)
 
-    return sorted(list(set(flattened_licenses(value, _choose))), key=str.casefold)
+    return sorted(list(set(flattened_licenses(None, value, _choose))), key=str.casefold)
diff --git a/meta/lib/oe/spdx30_tasks.py b/meta/lib/oe/spdx30_tasks.py
index 79e18db11d..987f8692f6 100644
--- a/meta/lib/oe/spdx30_tasks.py
+++ b/meta/lib/oe/spdx30_tasks.py
@@ -6,12 +6,14 @@
 
 import json
 import oe.cve_check
+import oe.license
 import oe.packagedata
 import oe.patch
 import oe.sbom30
+import oe.sdk
 import oe.spdx30
 import oe.spdx_common
-import oe.sdk
+import oe.spdx_license
 import os
 import re
 import urllib.parse
@@ -37,101 +39,52 @@ def set_timestamp_now(d, o, prop):
 def add_license_expression(
     d, objset, license_expression, license_data, search_objsets=[]
 ):
-    simple_license_text = {}
     license_text_map = {}
+    pn = d.getVar("PN")
 
-    def add_license_text(name):
-        nonlocal objset
-        nonlocal simple_license_text
-
-        if name in simple_license_text:
-            return simple_license_text[name]
-
-        for o in [objset] + search_objsets:
-            lic = o.find_filter(
-                oe.spdx30.simplelicensing_SimpleLicensingText,
-                name=name,
-            )
-
-            if lic is not None:
-                simple_license_text[name] = lic
-                return lic
-
-        lic = objset.add(
-            oe.spdx30.simplelicensing_SimpleLicensingText(
-                _id=objset.new_spdxid("license-text", name),
-                creationInfo=objset.doc.creationInfo,
-                name=name,
-            )
-        )
-        objset.set_element_alias(lic)
-        simple_license_text[name] = lic
-
-        if name == "PD":
-            lic.simplelicensing_licenseText = "Software released to the public domain"
-            return lic
-
-        # Seach for the license in COMMON_LICENSE_DIR and LICENSE_PATH
-        for directory in [d.getVar("COMMON_LICENSE_DIR")] + (
-            d.getVar("LICENSE_PATH") or ""
-        ).split():
-            try:
-                with (Path(directory) / name).open(errors="replace") as f:
-                    lic.simplelicensing_licenseText = f.read()
-                    return lic
-
-            except FileNotFoundError:
-                pass
-
-        # If it's not SPDX or PD, then NO_GENERIC_LICENSE must be set
-        filename = d.getVarFlag("NO_GENERIC_LICENSE", name)
-        if filename:
-            filename = d.expand("${S}/" + filename)
-            with open(filename, errors="replace") as f:
-                lic.simplelicensing_licenseText = f.read()
-                return lic
-        else:
-            bb.fatal("Cannot find any text for license %s" % name)
-
-    def convert(l):
+    def walk_license(node):
         nonlocal license_text_map
 
-        if l == "(" or l == ")":
-            return l
-
-        if l == "&":
-            return "AND"
-
-        if l == "|":
-            return "OR"
-
-        if l == "CLOSED":
-            return "NONE"
+        if isinstance(node, oe.spdx_license.LicenseRef):
+            if node.ident not in license_text_map:
+                lic = None
+                for o in [objset] + search_objsets:
+                    lic = o.find_filter(
+                        oe.spdx30.simplelicensing_SimpleLicensingText,
+                        name=node.name,
+                    )
+                    if lic:
+                        break
 
-        spdx_license = d.getVarFlag("SPDXLICENSEMAP", l) or l
-        if spdx_license in license_data["licenses"]:
-            return spdx_license
+                if lic is None:
+                    p, _ = oe.license.get_license_path(d, node)
+                    if p is None:
+                        bb.fatal(f"{pn}: No generic license file exists for: {node.ident} in any provider")
+                        return
+
+                    with open(p, errors="replace") as f:
+                        lic_text = f.read()
+
+                    lic = objset.add(
+                        oe.spdx30.simplelicensing_SimpleLicensingText(
+                            _id=objset.new_spdxid("license-text", node.name),
+                            creationInfo=objset.doc.creationInfo,
+                            name=node.name,
+                            simplelicensing_licenseText=lic_text,
+                        )
+                    )
+                    objset.set_element_alias(lic)
 
-        spdx_license = "LicenseRef-" + l
-        if spdx_license not in license_text_map:
-            license_text_map[spdx_license] = oe.sbom30.get_element_link_id(
-                add_license_text(l)
-            )
+                license_text_map[node.ident] = lic
 
-        return spdx_license
+        for child in node.children:
+            walk_license(child)
 
-    lic_split = (
-        license_expression.replace("(", " ( ")
-        .replace(")", " ) ")
-        .replace("|", " | ")
-        .replace("&", " & ")
-        .split()
-    )
-    spdx_license_expression = " ".join(convert(l) for l in lic_split)
+    s = oe.license.parse_legacy_license(d, license_expression)
+    if s is None:
+        return None
 
-    o = objset.new_license_expression(
-        spdx_license_expression, license_data, license_text_map
-    )
+    o = objset.new_license_expression(s.to_string(), license_data, license_text_map)
     objset.set_element_alias(o)
     return o
 
@@ -862,11 +815,18 @@ def create_spdx(d):
     recipe_spdx_license = add_license_expression(
         d, build_objset, d.getVar("LICENSE"), license_data, [recipe_objset]
     )
-    build_objset.new_relationship(
-        source_files,
-        oe.spdx30.RelationshipType.hasDeclaredLicense,
-        [oe.sbom30.get_element_link_id(recipe_spdx_license)],
-    )
+    if recipe_spdx_license:
+        build_objset.new_relationship(
+            source_files,
+            oe.spdx30.RelationshipType.hasDeclaredLicense,
+            [oe.sbom30.get_element_link_id(recipe_spdx_license)],
+        )
+    else:
+        build_objset.new_relationship(
+            source_files,
+            oe.spdx30.RelationshipType.hasDeclaredLicense,
+            None,
+        )
 
     dep_sources = {}
     if oe.spdx_common.process_sources(d) and include_sources:
@@ -1022,11 +982,18 @@ def create_spdx(d):
             else:
                 package_spdx_license = recipe_spdx_license
 
-            pkg_objset.new_relationship(
-                [spdx_package],
-                oe.spdx30.RelationshipType.hasDeclaredLicense,
-                [oe.sbom30.get_element_link_id(package_spdx_license)],
-            )
+            if package_spdx_license:
+                pkg_objset.new_relationship(
+                    [spdx_package],
+                    oe.spdx30.RelationshipType.hasDeclaredLicense,
+                    [oe.sbom30.get_element_link_id(package_spdx_license)],
+                )
+            else:
+                pkg_objset.new_relationship(
+                    [spdx_package],
+                    oe.spdx30.RelationshipType.hasDeclaredLicense,
+                    None,
+                )
 
             # Add concluded license relationship if manually set
             # Only add when license analysis has been explicitly performed
@@ -1037,12 +1004,12 @@ def create_spdx(d):
                 concluded_spdx_license = add_license_expression(
                     d, build_objset, concluded_license_str, license_data
                 )
-
-                pkg_objset.new_relationship(
-                    [spdx_package],
-                    oe.spdx30.RelationshipType.hasConcludedLicense,
-                    [oe.sbom30.get_element_link_id(concluded_spdx_license)],
-                )
+                if concluded_spdx_license:
+                    pkg_objset.new_relationship(
+                        [spdx_package],
+                        oe.spdx30.RelationshipType.hasConcludedLicense,
+                        [oe.sbom30.get_element_link_id(concluded_spdx_license)],
+                    )
 
             bb.debug(1, "Adding package files to SPDX for package %s" % pkg_name)
             package_files, excluded_files = add_package_files(
diff --git a/meta/lib/oeqa/selftest/cases/incompatible_lic.py b/meta/lib/oeqa/selftest/cases/incompatible_lic.py
index 12f4d14f16..5467640cdd 100644
--- a/meta/lib/oeqa/selftest/cases/incompatible_lic.py
+++ b/meta/lib/oeqa/selftest/cases/incompatible_lic.py
@@ -96,7 +96,7 @@ class IncompatibleLicenseTests(OESelftestTestCase):
     # Verify that a package with a non-SPDX license cannot be built when
     # INCOMPATIBLE_LICENSE contains this license
     def test_incompatible_nonspdx_license(self):
-        self.lic_test('incompatible-nonspdx-license', 'FooLicense', 'FooLicense')
+        self.lic_test('incompatible-nonspdx-license', 'LicenseRef-FooLicense', 'LicenseRef-FooLicense')
 
 class IncompatibleLicensePerImageTests(OESelftestTestCase):
     def default_config(self):
@@ -104,6 +104,7 @@ class IncompatibleLicensePerImageTests(OESelftestTestCase):
 IMAGE_INSTALL:append = " bash"
 INCOMPATIBLE_LICENSE:pn-core-image-minimal = "GPL-3.0* LGPL-3.0*"
 MACHINE_ESSENTIAL_EXTRA_RDEPENDS:remove = "tar"
+NO_GENERIC_LICENSE[SomeLicense] = "COPYING"
 """
 
     def test_bash_default(self):
@@ -116,7 +117,7 @@ MACHINE_ESSENTIAL_EXTRA_RDEPENDS:remove = "tar"
 
     def test_bash_and_license(self):
         self.disable_class("create-spdx")
-        self.write_config(self.default_config() + '\nLICENSE:append:pn-bash = " & SomeLicense"\nERROR_QA:remove:pn-bash = "license-exists"')
+        self.write_config(self.default_config() + '\nLICENSE:append:pn-bash = " AND LicenseRef-SomeLicense"\nERROR_QA:remove:pn-bash = "license-exists"')
         error_msg = "ERROR: core-image-minimal-1.0-r0 do_rootfs: Some packages cannot be installed into the image because they have incompatible licenses:\n\tbash (GPL-3.0-or-later)"
 
         result = bitbake('core-image-minimal', ignore_status=True)
@@ -125,7 +126,7 @@ MACHINE_ESSENTIAL_EXTRA_RDEPENDS:remove = "tar"
 
     def test_bash_or_license(self):
         self.disable_class("create-spdx")
-        self.write_config(self.default_config() + '\nLICENSE:append:pn-bash = " | SomeLicense"\nERROR_QA:remove:pn-bash = "license-exists"\nERROR_QA:remove:pn-core-image-minimal = "license-file-missing"')
+        self.write_config(self.default_config() + '\nLICENSE:append:pn-bash = " OR LicenseRef-SomeLicense"\nERROR_QA:remove:pn-bash = "license-exists"\nERROR_QA:remove:pn-core-image-minimal = "license-file-missing"')
 
         bitbake('core-image-minimal')
 
diff --git a/meta/lib/oeqa/selftest/cases/oelib/license.py b/meta/lib/oeqa/selftest/cases/oelib/license.py
index 49b28951f7..33a08ada67 100644
--- a/meta/lib/oeqa/selftest/cases/oelib/license.py
+++ b/meta/lib/oeqa/selftest/cases/oelib/license.py
@@ -6,14 +6,8 @@
 
 from unittest.case import TestCase
 import oe.license
+import oe.spdx_license
 
-class SeenVisitor(oe.license.LicenseVisitor):
-    def __init__(self):
-        self.seen = []
-        oe.license.LicenseVisitor.__init__(self)
-
-    def visit_Constant(self, node):
-        self.seen.append(node.value)
 
 class TestSingleLicense(TestCase):
     licenses = [
@@ -22,84 +16,266 @@ class TestSingleLicense(TestCase):
         "Artistic-1.0",
         "MIT",
         "GPL-3.0-or-later",
-        "FOO_BAR",
+        "LicenseRef-FOO-BAR",
     ]
     invalid_licenses = ["GPL/BSD"]
 
     @staticmethod
     def parse(licensestr):
-        visitor = SeenVisitor()
-        visitor.visit_string(licensestr)
-        return visitor.seen
+        return oe.spdx_license.parse(licensestr)
 
     def test_single_licenses(self):
         for license in self.licenses:
-            licenses = self.parse(license)
-            self.assertListEqual(licenses, [license])
+            n = oe.spdx_license.parse(license)
+            self.assertListEqual([n.ident], [license])
 
     def test_invalid_licenses(self):
         for license in self.invalid_licenses:
-            with self.assertRaises(oe.license.InvalidLicense) as cm:
-                self.parse(license)
-            self.assertEqual(cm.exception.license, license)
+            with self.assertRaises(oe.spdx_license.ParseError) as cm:
+                oe.spdx_license.parse(license)
+            self.assertEqual(cm.exception.expression, license)
+
 
 class TestSimpleCombinations(TestCase):
-    tests = {
-        "FOO&BAR": ["FOO", "BAR"],
-        "BAZ & MOO": ["BAZ", "MOO"],
-        "ALPHA|BETA": ["ALPHA"],
-        "BAZ&MOO|FOO": ["FOO"],
-        "FOO&BAR|BAZ": ["FOO", "BAR"],
-    }
-    preferred = ["ALPHA", "FOO", "BAR"]
+    tests = [
+        (
+            "LicenseRef-FOO AND LicenseRef-BAR",
+            ["LicenseRef-FOO", "LicenseRef-BAR"],
+        ),
+        (
+            "LicenseRef-BAZ AND LicenseRef-MOO",
+            ["LicenseRef-BAZ", "LicenseRef-MOO"],
+        ),
+        (
+            "LicenseRef-ALPHA OR LicenseRef-BETA",
+            ["LicenseRef-ALPHA"],
+        ),
+        (
+            "LicenseRef-BAZ AND LicenseRef-MOO OR LicenseRef-FOO",
+            ["LicenseRef-FOO"],
+        ),
+        (
+            "LicenseRef-FOO AND LicenseRef-BAR OR LicenseRef-BAZ",
+            ["LicenseRef-FOO", "LicenseRef-BAR"],
+        ),
+    ]
+    preferred = ["LicenseRef-ALPHA", "LicenseRef-FOO", "LicenseRef-BAR"]
 
     def test_tests(self):
         def choose(a, b):
+            print(a, b)
             if all(lic in self.preferred for lic in b):
                 return b
             else:
                 return a
 
-        for license, expected in self.tests.items():
-            licenses = oe.license.flattened_licenses(license, choose)
+        for license, expected in self.tests:
+            licenses = oe.license.flattened_licenses(None, license, choose)
             self.assertListEqual(licenses, expected)
 
+
 class TestComplexCombinations(TestSimpleCombinations):
-    tests = {
-        "FOO & (BAR | BAZ)&MOO": ["FOO", "BAR", "MOO"],
-        "(ALPHA|(BETA&THETA)|OMEGA)&DELTA": ["OMEGA", "DELTA"],
-        "((ALPHA|BETA)&FOO)|BAZ": ["BETA", "FOO"],
-        "(GPL-2.0-only|Proprietary)&BSD-4-clause&MIT": ["GPL-2.0-only", "BSD-4-clause", "MIT"],
-    }
-    preferred = ["BAR", "OMEGA", "BETA", "GPL-2.0-only"]
+    tests = [
+        (
+            "LicenseRef-FOO AND (LicenseRef-BAR OR LicenseRef-BAZ) AND LicenseRef-MOO",
+            ["LicenseRef-FOO", "LicenseRef-BAR", "LicenseRef-MOO"],
+        ),
+        (
+            "(LicenseRef-ALPHA OR (LicenseRef-BETA AND LicenseRef-THETA) OR LicenseRef-OMEGA) AND LicenseRef-DELTA",
+            ["LicenseRef-OMEGA", "LicenseRef-DELTA"],
+        ),
+        (
+            "((LicenseRef-ALPHA OR LicenseRef-BETA) AND LicenseRef-FOO) OR LicenseRef-BAZ",
+            ["LicenseRef-BETA", "LicenseRef-FOO"],
+        ),
+        (
+            "(GPL-2.0-only OR LicenseRef-Proprietary) AND BSD-4-clause AND MIT",
+            ["GPL-2.0-only", "BSD-4-Clause", "MIT"],
+        ),
+    ]
+    preferred = [
+        "LicenseRef-BAR",
+        "LicenseRef-OMEGA",
+        "LicenseRef-BETA",
+        "GPL-2.0-only",
+    ]
+
 
 class TestIsIncluded(TestCase):
-    tests = {
-        ("FOO | BAR", None, None):
-            [True, ["FOO"]],
-        ("FOO | BAR", None, "FOO"):
-            [True, ["BAR"]],
-        ("FOO | BAR", "BAR", None):
-            [True, ["BAR"]],
-        ("FOO | BAR & FOOBAR", "*BAR", None):
-            [True, ["BAR", "FOOBAR"]],
-        ("FOO | BAR & FOOBAR", None, "FOO*"):
-            [False, ["FOOBAR"]],
-        ("(FOO | BAR) & FOOBAR | BARFOO", None, "FOO"):
-            [True, ["BAR", "FOOBAR"]],
-        ("(FOO | BAR) & FOOBAR | BAZ & MOO & BARFOO", None, "FOO"):
-            [True, ["BAZ", "MOO", "BARFOO"]],
-        ("GPL-3.0-or-later & GPL-2.0-only & LGPL-2.1-only | Proprietary", None, None):
-            [True, ["GPL-3.0-or-later", "GPL-2.0-only", "LGPL-2.1-only"]],
-        ("GPL-3.0-or-later & GPL-2.0-only & LGPL-2.1-only | Proprietary", None, "GPL-3.0-or-later"):
-            [True, ["Proprietary"]],
-        ("GPL-3.0-or-later & GPL-2.0-only & LGPL-2.1-only | Proprietary", None, "GPL-3.0-or-later Proprietary"):
-            [False, ["GPL-3.0-or-later"]]
-    }
+    tests = [
+        (
+            "LicenseRef-FOO OR LicenseRef-BAR",
+            None,
+            None,
+            True,
+            ["LicenseRef-FOO"],
+        ),
+        (
+            "LicenseRef-FOO OR LicenseRef-BAR",
+            None,
+            "LicenseRef-FOO",
+            True,
+            ["LicenseRef-BAR"],
+        ),
+        (
+            "LicenseRef-FOO OR LicenseRef-BAR",
+            "LicenseRef-BAR",
+            None,
+            True,
+            ["LicenseRef-BAR"],
+        ),
+        (
+            "LicenseRef-FOO OR LicenseRef-BAR AND LicenseRef-FOOBAR",
+            "*BAR",
+            None,
+            True,
+            ["LicenseRef-BAR", "LicenseRef-FOOBAR"],
+        ),
+        (
+            "LicenseRef-FOO OR LicenseRef-BAR AND LicenseRef-FOOBAR",
+            None,
+            "LicenseRef-FOO*",
+            False,
+            ["LicenseRef-FOOBAR"],
+        ),
+        (
+            "(LicenseRef-FOO OR LicenseRef-BAR) AND LicenseRef-FOOBAR OR LicenseRef-BARFOO",
+            None,
+            "LicenseRef-FOO",
+            True,
+            ["LicenseRef-BAR", "LicenseRef-FOOBAR"],
+        ),
+        (
+            "(FOO OR BAR) AND FOOBAR OR BAZ AND MOO AND BARFOO",
+            None,
+            "FOO",
+            True,
+            ["LicenseRef-BAZ", "LicenseRef-MOO", "LicenseRef-BARFOO"],
+        ),
+        (
+            "GPL-3.0-or-later AND GPL-2.0-only AND LGPL-2.1-only OR Proprietary",
+            None,
+            None,
+            True,
+            ["GPL-3.0-or-later", "GPL-2.0-only", "LGPL-2.1-only"],
+        ),
+        (
+            "GPL-3.0-or-later AND GPL-2.0-only AND LGPL-2.1-only OR Proprietary",
+            None,
+            "GPL-3.0-or-later",
+            True,
+            ["LicenseRef-Proprietary"],
+        ),
+        (
+            "GPL-3.0-or-later AND GPL-2.0-only AND LGPL-2.1-only OR Proprietary",
+            None,
+            "GPL-3.0-or-later LicenseRef-Proprietary",
+            False,
+            ["GPL-3.0-or-later"],
+        ),
+    ]
 
     def test_tests(self):
-        for args, expected in self.tests.items():
-            is_included, licenses = oe.license.is_included(
-                args[0], (args[1] or '').split(), (args[2] or '').split())
-            self.assertEqual(is_included, expected[0])
-            self.assertListEqual(licenses, expected[1])
+        for (
+            expression,
+            include,
+            exclude,
+            expect_included,
+            expect_licenses,
+        ) in self.tests:
+            actual_included, actual_licenses = oe.license.is_included(
+                None,
+                expression,
+                (include or "").split(),
+                (exclude or "").split(),
+            )
+            self.assertEqual(actual_included, expect_included)
+            self.assertListEqual(actual_licenses, expect_licenses)
+
+
+class TestSort(TestCase):
+    tests = [
+        (
+            "MIT AND MIT",
+            "MIT",
+        ),
+        (
+            "MIT OR MIT",
+            "MIT",
+        ),
+        (
+            "MIT AND 0BSD",
+            "0BSD AND MIT",
+        ),
+        (
+            "MIT OR 0BSD",
+            "0BSD OR MIT",
+        ),
+        (
+            "(MIT AND 0BSD) AND (MIT AND 0BSD)",
+            "0BSD AND MIT",
+        ),
+        (
+            "(MIT AND 0BSD) OR (MIT AND GPL-3.0-or-later)",
+            "0BSD AND MIT OR GPL-3.0-or-later AND MIT",
+        ),
+        (
+            "(MIT OR 0BSD) AND (MIT OR GPL-3.0-or-later)",
+            "(0BSD OR MIT) AND (GPL-3.0-or-later OR MIT)",
+        ),
+        (
+            "(MIT AND 0BSD) AND (MIT AND GPL-3.0-or-later)",
+            "0BSD AND GPL-3.0-or-later AND MIT",
+        ),
+        (
+            "(MIT OR 0BSD) OR (MIT OR GPL-3.0-or-later)",
+            "0BSD OR GPL-3.0-or-later OR MIT",
+        ),
+        (
+            "((MIT OR 0BSD) OR MIT) OR GPL-3.0-or-later",
+            "0BSD OR GPL-3.0-or-later OR MIT",
+        ),
+        (
+            "MIT OR (0BSD OR (MIT OR GPL-3.0-or-later))",
+            "0BSD OR GPL-3.0-or-later OR MIT",
+        ),
+        (
+            "MIT OR (0BSD OR MIT) OR GPL-3.0-or-later",
+            "0BSD OR GPL-3.0-or-later OR MIT",
+        ),
+        (
+            "(MIT AND (0BSD OR MIT)) AND (0BSD AND GPL-3.0-or-later)",
+            "0BSD AND GPL-3.0-or-later AND MIT AND (0BSD OR MIT)",
+        ),
+        (
+            "(MIT OR (0BSD AND MIT)) OR (0BSD OR GPL-3.0-or-later)",
+            "0BSD AND MIT OR 0BSD OR GPL-3.0-or-later OR MIT",
+        ),
+        (
+            "(MIT AND ((0BSD OR MIT) AND LGPL-3.0-or-later)) AND (0BSD AND GPL-3.0-or-later)",
+            "0BSD AND GPL-3.0-or-later AND LGPL-3.0-or-later AND MIT AND (0BSD OR MIT)",
+        ),
+        (
+            "(MIT AND ((0BSD OR MIT) AND LGPL-3.0-or-later)) AND ((0BSD AND (0BSD OR MIT)) AND GPL-3.0-or-later)",
+            "0BSD AND GPL-3.0-or-later AND LGPL-3.0-or-later AND MIT AND (0BSD OR MIT)",
+        ),
+        (
+            # GPL-3.0-later is split across an otherwise sorted list; make sure
+            # it can be detected and merged
+            "(0BSD AND GPL-3.0-or-later) AND (GPL-3.0-or-later AND MIT)",
+            "0BSD AND GPL-3.0-or-later AND MIT",
+        ),
+        (
+            "GPL-3.0-or-later WITH GCC-exception-3.1 AND GPL-3.0-or-later WITH GCC-exception-3.1",
+            "GPL-3.0-or-later WITH GCC-exception-3.1",
+        ),
+        (
+            "GPL-3.0-or-later WITH GCC-exception-3.1 AND GPL-3.0-or-later",
+            "GPL-3.0-or-later AND GPL-3.0-or-later WITH GCC-exception-3.1",
+        ),
+    ]
+
+    def test_sort(self):
+        for expression, expected in self.tests:
+            actual = oe.spdx_license.parse(expression).sort()
+            self.assertEqual(actual.to_string(), expected)
