Repository: spack/spack

Issue:
Misleading error message for non-buildable package
### Steps to reproduce

My system has cmake 3.26.3 installed as a pre-built module, so I added it as an external package in `.spack/packages.yaml`
```console
~$ module avail cmake

----------------------------------------------- /auto/software/swtree/ubuntu22.04/x86_64/modulefiles -----------------------------------------------
   cmake/3.22.5    cmake/3.26.3 (L,D)
~$ head -6 $HOME/.spack/packages.yaml
packages:
  cmake:
    externals:
    - spec: cmake@3.26.3%gcc@12.2.0
      prefix: /auto/software/swtree/ubuntu22.04/x86_64/cmake/3.26.3
    buildable: False
```

However, I wanted to build some spack packages using an older version of GCC (11.3.0) than was used to build the system cmake module (12), so I tried to install a separate version. I forgot that I had set `buildable: False`, which disallows any additional spack install of cmake 3.26.3. However, the error message was misleading - it suggests that the problem is the compiler version, rather than the fact that the package is not buildable. 

```console
~$ spack install cmake@3.26.3%gcc@11.3.0
==> Error: concretization failed for the following reasons:

   1. No valid version for 'cmake' compiler 'gcc' satisfies '@11.3.0'
~$ spack compiler info gcc@11.3.0
gcc@11.3.0:
        paths:
                cc = /auto/software/swtree/ubuntu20.04/x86_64/gcc/11.3.0/bin/gcc
                cxx = /auto/software/swtree/ubuntu20.04/x86_64/gcc/11.3.0/bin/g++
                f77 = /auto/software/swtree/ubuntu20.04/x86_64/gcc/11.3.0/bin/gfortran
                fc = /auto/software/swtree/ubuntu20.04/x86_64/gcc/11.3.0/bin/gfortran
        modules  = []
        operating system  = ubuntu20.04
gcc@11.3.0:
        paths:
                cc = /usr/bin/gcc
                cxx = /usr/bin/g++
                f77 = /usr/bin/gfortran
                fc = /usr/bin/gfortran
        modules  = []
        operating system  = ubuntu22.04
gcc@11.3.0:
        paths:
                cc = /auto/software/gcc/x86_64/gcc-11.3.0/bin/gcc
                cxx = /auto/software/gcc/x86_64/gcc-11.3.0/bin/g++
                f77 = /auto/software/gcc/x86_64/gcc-11.3.0/bin/gfortran
                fc = /auto/software/gcc/x86_64/gcc-11.3.0/bin/gfortran
        modules  = []
        operating system  = centos7
```

Once I removed `buildable: False`, I was able to run `spack install cmake@3.26.3%gcc@11.3.0` successfully.



### Error message

```console
~$ spack install cmake@3.26.3%gcc@11.3.0
==> Error: concretization failed for the following reasons:

   1. No valid version for 'cmake' compiler 'gcc' satisfies '@11.3.0'
```

### Information on your system

* **Spack:** 0.21.0.dev0 (29aa7117f42f758bc537e03e4bedf66ced0accfa)
* **Python:** 3.10.12
* **Platform:** linux-ubuntu22.04-broadwell
* **Concretizer:** clingo

### General information

- [X] I have run `spack debug report` and reported the version of Spack/Python/Platform
- [X] I have searched the issues of this repo and believe this is not a duplicate
- [X] I have run the failing commands in debug mode and reported the output


Discussion:

Resolving patch:
diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py
--- a/lib/spack/spack/environment/environment.py
+++ b/lib/spack/spack/environment/environment.py
@@ -1525,7 +1525,11 @@ def _concretize_separately(self, tests=False):
         batch = []
         for j, (i, concrete, duration) in enumerate(
             spack.util.parallel.imap_unordered(
-                _concretize_task, args, processes=num_procs, debug=tty.is_debug()
+                _concretize_task,
+                args,
+                processes=num_procs,
+                debug=tty.is_debug(),
+                maxtaskperchild=1,
             )
         ):
             batch.append((i, concrete))
diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py
--- a/lib/spack/spack/solver/asp.py
+++ b/lib/spack/spack/solver/asp.py
@@ -8,11 +8,12 @@
 import enum
 import itertools
 import os
+import pathlib
 import pprint
 import re
 import types
 import warnings
-from typing import Dict, List, NamedTuple, Optional, Sequence, Tuple, Union
+from typing import Callable, Dict, List, NamedTuple, Optional, Sequence, Set, Tuple, Union
 
 import archspec.cpu
 
@@ -337,6 +338,13 @@ def __getattr__(self, name):
 
 fn = AspFunctionBuilder()
 
+TransformFunction = Callable[[spack.spec.Spec, List[AspFunction]], List[AspFunction]]
+
+
+def remove_node(spec: spack.spec.Spec, facts: List[AspFunction]) -> List[AspFunction]:
+    """Transformation that removes all "node" and "virtual_node" from the input list of facts."""
+    return list(filter(lambda x: x.args[0] not in ("node", "virtual_node"), facts))
+
 
 def _create_counter(specs, tests):
     strategy = spack.config.CONFIG.get("concretizer:duplicates:strategy", "none")
@@ -684,7 +692,7 @@ def extract_args(model, predicate_name):
 class ErrorHandler:
     def __init__(self, model):
         self.model = model
-        self.error_args = extract_args(model, "error")
+        self.full_model = None
 
     def multiple_values_error(self, attribute, pkg):
         return f'Cannot select a single "{attribute}" for package "{pkg}"'
@@ -692,6 +700,48 @@ def multiple_values_error(self, attribute, pkg):
     def no_value_error(self, attribute, pkg):
         return f'Cannot select a single "{attribute}" for package "{pkg}"'
 
+    def _get_cause_tree(
+        self,
+        cause: Tuple[str, str],
+        conditions: Dict[str, str],
+        condition_causes: List[Tuple[Tuple[str, str], Tuple[str, str]]],
+        seen: Set,
+        indent: str = "        ",
+    ) -> List[str]:
+        """
+        Implementation of recursion for self.get_cause_tree. Much of this operates on tuples
+        (condition_id, set_id) in which the latter idea means that the condition represented by
+        the former held in the condition set represented by the latter.
+        """
+        seen = set(seen) | set(cause)
+        parents = [c for e, c in condition_causes if e == cause and c not in seen]
+        local = "required because %s " % conditions[cause[0]]
+
+        return [indent + local] + [
+            c
+            for parent in parents
+            for c in self._get_cause_tree(
+                parent, conditions, condition_causes, seen, indent=indent + "  "
+            )
+        ]
+
+    def get_cause_tree(self, cause: Tuple[str, str]) -> List[str]:
+        """
+        Get the cause tree associated with the given cause.
+
+        Arguments:
+            cause: The root cause of the tree (final condition)
+
+        Returns:
+            A list of strings describing the causes, formatted to display tree structure.
+        """
+        conditions: Dict[str, str] = dict(extract_args(self.full_model, "condition_reason"))
+        condition_causes: List[Tuple[Tuple[str, str], Tuple[str, str]]] = list(
+            ((Effect, EID), (Cause, CID))
+            for Effect, EID, Cause, CID in extract_args(self.full_model, "condition_cause")
+        )
+        return self._get_cause_tree(cause, conditions, condition_causes, set())
+
     def handle_error(self, msg, *args):
         """Handle an error state derived by the solver."""
         if msg == "multiple_values_error":
@@ -700,14 +750,31 @@ def handle_error(self, msg, *args):
         if msg == "no_value_error":
             return self.no_value_error(*args)
 
+        try:
+            idx = args.index("startcauses")
+        except ValueError:
+            msg_args = args
+            causes = []
+        else:
+            msg_args = args[:idx]
+            cause_args = args[idx + 1 :]
+            cause_args_conditions = cause_args[::2]
+            cause_args_ids = cause_args[1::2]
+            causes = list(zip(cause_args_conditions, cause_args_ids))
+
+        msg = msg.format(*msg_args)
+
         # For variant formatting, we sometimes have to construct specs
         # to format values properly. Find/replace all occurances of
         # Spec(...) with the string representation of the spec mentioned
-        msg = msg.format(*args)
         specs_to_construct = re.findall(r"Spec\(([^)]*)\)", msg)
         for spec_str in specs_to_construct:
             msg = msg.replace("Spec(%s)" % spec_str, str(spack.spec.Spec(spec_str)))
 
+        for cause in set(causes):
+            for c in self.get_cause_tree(cause):
+                msg += f"\n{c}"
+
         return msg
 
     def message(self, errors) -> str:
@@ -719,11 +786,31 @@ def message(self, errors) -> str:
         return "\n".join([header] + messages)
 
     def raise_if_errors(self):
-        if not self.error_args:
+        initial_error_args = extract_args(self.model, "error")
+        if not initial_error_args:
             return
 
+        error_causation = clingo.Control()
+
+        parent_dir = pathlib.Path(__file__).parent
+        errors_lp = parent_dir / "error_messages.lp"
+
+        def on_model(model):
+            self.full_model = model.symbols(shown=True, terms=True)
+
+        with error_causation.backend() as backend:
+            for atom in self.model:
+                atom_id = backend.add_atom(atom)
+                backend.add_rule([atom_id], [], choice=False)
+
+            error_causation.load(str(errors_lp))
+            error_causation.ground([("base", []), ("error_messages", [])])
+            _ = error_causation.solve(on_model=on_model)
+
+        # No choices so there will be only one model
+        error_args = extract_args(self.full_model, "error")
         errors = sorted(
-            [(int(priority), msg, args) for priority, msg, *args in self.error_args], reverse=True
+            [(int(priority), msg, args) for priority, msg, *args in error_args], reverse=True
         )
         msg = self.message(errors)
         raise UnsatisfiableSpecError(msg)
@@ -924,7 +1011,7 @@ def on_model(model):
                 if sym.name not in ("attr", "error", "opt_criterion"):
                     tty.debug(
                         "UNKNOWN SYMBOL: %s(%s)"
-                        % (sym.name, ", ".join(intermediate_repr(sym.arguments)))
+                        % (sym.name, ", ".join([str(s) for s in intermediate_repr(sym.arguments)]))
                     )
 
         elif cores:
@@ -1116,7 +1203,7 @@ def conflict_rules(self, pkg):
         default_msg = "{0}: '{1}' conflicts with '{2}'"
         no_constraint_msg = "{0}: conflicts with '{1}'"
         for trigger, constraints in pkg.conflicts.items():
-            trigger_msg = "conflict trigger %s" % str(trigger)
+            trigger_msg = f"conflict is triggered when {str(trigger)}"
             trigger_spec = spack.spec.Spec(trigger)
             trigger_id = self.condition(
                 trigger_spec, name=trigger_spec.name or pkg.name, msg=trigger_msg
@@ -1128,7 +1215,11 @@ def conflict_rules(self, pkg):
                         conflict_msg = no_constraint_msg.format(pkg.name, trigger)
                     else:
                         conflict_msg = default_msg.format(pkg.name, trigger, constraint)
-                constraint_msg = "conflict constraint %s" % str(constraint)
+
+                spec_for_msg = (
+                    spack.spec.Spec(pkg.name) if constraint == spack.spec.Spec() else constraint
+                )
+                constraint_msg = f"conflict applies to spec {str(spec_for_msg)}"
                 constraint_id = self.condition(constraint, name=pkg.name, msg=constraint_msg)
                 self.gen.fact(
                     fn.pkg_fact(pkg.name, fn.conflict(trigger_id, constraint_id, conflict_msg))
@@ -1310,7 +1401,7 @@ def trigger_rules(self):
         self.gen.h2("Trigger conditions")
         for name in self._trigger_cache:
             cache = self._trigger_cache[name]
-            for spec_str, (trigger_id, requirements) in cache.items():
+            for (spec_str, _), (trigger_id, requirements) in cache.items():
                 self.gen.fact(fn.pkg_fact(name, fn.trigger_id(trigger_id)))
                 self.gen.fact(fn.pkg_fact(name, fn.trigger_msg(spec_str)))
                 for predicate in requirements:
@@ -1323,7 +1414,7 @@ def effect_rules(self):
         self.gen.h2("Imposed requirements")
         for name in self._effect_cache:
             cache = self._effect_cache[name]
-            for spec_str, (effect_id, requirements) in cache.items():
+            for (spec_str, _), (effect_id, requirements) in cache.items():
                 self.gen.fact(fn.pkg_fact(name, fn.effect_id(effect_id)))
                 self.gen.fact(fn.pkg_fact(name, fn.effect_msg(spec_str)))
                 for predicate in requirements:
@@ -1422,18 +1513,26 @@ def variant_rules(self, pkg):
 
             self.gen.newline()
 
-    def condition(self, required_spec, imposed_spec=None, name=None, msg=None, node=False):
+    def condition(
+        self,
+        required_spec: spack.spec.Spec,
+        imposed_spec: Optional[spack.spec.Spec] = None,
+        name: Optional[str] = None,
+        msg: Optional[str] = None,
+        transform_required: Optional[TransformFunction] = None,
+        transform_imposed: Optional[TransformFunction] = remove_node,
+    ):
         """Generate facts for a dependency or virtual provider condition.
 
         Arguments:
-            required_spec (spack.spec.Spec): the spec that triggers this condition
-            imposed_spec (spack.spec.Spec or None): the spec with constraints that
-                are imposed when this condition is triggered
-            name (str or None): name for `required_spec` (required if
-                required_spec is anonymous, ignored if not)
-            msg (str or None): description of the condition
-            node (bool): if False does not emit "node" or "virtual_node" requirements
-                from the imposed spec
+            required_spec: the constraints that triggers this condition
+            imposed_spec: the constraints that are imposed when this condition is triggered
+            name: name for `required_spec` (required if required_spec is anonymous, ignored if not)
+            msg: description of the condition
+            transform_required: transformation applied to facts from the required spec. Defaults
+                to leave facts as they are.
+            transform_imposed: transformation applied to facts from the imposed spec. Defaults
+                to removing "node" and "virtual_node" facts.
         Returns:
             int: id of the condition created by this function
         """
@@ -1451,10 +1550,14 @@ def condition(self, required_spec, imposed_spec=None, name=None, msg=None, node=
 
         cache = self._trigger_cache[named_cond.name]
 
-        named_cond_key = str(named_cond)
+        named_cond_key = (str(named_cond), transform_required)
         if named_cond_key not in cache:
             trigger_id = next(self._trigger_id_counter)
             requirements = self.spec_clauses(named_cond, body=True, required_from=name)
+
+            if transform_required:
+                requirements = transform_required(named_cond, requirements)
+
             cache[named_cond_key] = (trigger_id, requirements)
         trigger_id, requirements = cache[named_cond_key]
         self.gen.fact(fn.pkg_fact(named_cond.name, fn.condition_trigger(condition_id, trigger_id)))
@@ -1463,14 +1566,14 @@ def condition(self, required_spec, imposed_spec=None, name=None, msg=None, node=
             return condition_id
 
         cache = self._effect_cache[named_cond.name]
-        imposed_spec_key = str(imposed_spec)
+        imposed_spec_key = (str(imposed_spec), transform_imposed)
         if imposed_spec_key not in cache:
             effect_id = next(self._effect_id_counter)
             requirements = self.spec_clauses(imposed_spec, body=False, required_from=name)
-            if not node:
-                requirements = list(
-                    filter(lambda x: x.args[0] not in ("node", "virtual_node"), requirements)
-                )
+
+            if transform_imposed:
+                requirements = transform_imposed(imposed_spec, requirements)
+
             cache[imposed_spec_key] = (effect_id, requirements)
         effect_id, requirements = cache[imposed_spec_key]
         self.gen.fact(fn.pkg_fact(named_cond.name, fn.condition_effect(condition_id, effect_id)))
@@ -1530,21 +1633,32 @@ def package_dependencies_rules(self, pkg):
                 if not depflag:
                     continue
 
-                msg = "%s depends on %s" % (pkg.name, dep.spec.name)
+                msg = f"{pkg.name} depends on {dep.spec}"
                 if cond != spack.spec.Spec():
-                    msg += " when %s" % cond
+                    msg += f" when {cond}"
                 else:
                     pass
 
-                condition_id = self.condition(cond, dep.spec, pkg.name, msg)
-                self.gen.fact(
-                    fn.pkg_fact(pkg.name, fn.dependency_condition(condition_id, dep.spec.name))
-                )
+                def track_dependencies(input_spec, requirements):
+                    return requirements + [fn.attr("track_dependencies", input_spec.name)]
 
-                for t in dt.ALL_FLAGS:
-                    if t & depflag:
-                        # there is a declared dependency of type t
-                        self.gen.fact(fn.dependency_type(condition_id, dt.flag_to_string(t)))
+                def dependency_holds(input_spec, requirements):
+                    return remove_node(input_spec, requirements) + [
+                        fn.attr(
+                            "dependency_holds", pkg.name, input_spec.name, dt.flag_to_string(t)
+                        )
+                        for t in dt.ALL_FLAGS
+                        if t & depflag
+                    ]
+
+                self.condition(
+                    cond,
+                    dep.spec,
+                    name=pkg.name,
+                    msg=msg,
+                    transform_required=track_dependencies,
+                    transform_imposed=dependency_holds,
+                )
 
                 self.gen.newline()
 
@@ -1639,8 +1753,17 @@ def emit_facts_from_requirement_rules(self, rules: List[RequirementRule]):
                     when_spec = spack.spec.Spec(pkg_name)
 
                 try:
+                    # With virtual we want to emit "node" and "virtual_node" in imposed specs
+                    transform: Optional[TransformFunction] = remove_node
+                    if virtual:
+                        transform = None
+
                     member_id = self.condition(
-                        required_spec=when_spec, imposed_spec=spec, name=pkg_name, node=virtual
+                        required_spec=when_spec,
+                        imposed_spec=spec,
+                        name=pkg_name,
+                        transform_imposed=transform,
+                        msg=f"{spec_str} is a requirement for package {pkg_name}",
                     )
                 except Exception as e:
                     # Do not raise if the rule comes from the 'all' subsection, since usability
@@ -1703,8 +1826,16 @@ def external_packages(self):
             # Declare external conditions with a local index into packages.yaml
             for local_idx, spec in enumerate(external_specs):
                 msg = "%s available as external when satisfying %s" % (spec.name, spec)
-                condition_id = self.condition(spec, msg=msg)
-                self.gen.fact(fn.pkg_fact(pkg_name, fn.possible_external(condition_id, local_idx)))
+
+                def external_imposition(input_spec, _):
+                    return [fn.attr("external_conditions_hold", input_spec.name, local_idx)]
+
+                self.condition(
+                    spec,
+                    spack.spec.Spec(spec.name),
+                    msg=msg,
+                    transform_imposed=external_imposition,
+                )
                 self.possible_versions[spec.name].add(spec.version)
                 self.gen.newline()
 
@@ -1918,6 +2049,7 @@ class Body:
         if not body:
             for virtual in virtuals:
                 clauses.append(fn.attr("provider_set", spec.name, virtual))
+                clauses.append(fn.attr("virtual_node", virtual))
         else:
             for virtual in virtuals:
                 clauses.append(fn.attr("virtual_on_incoming_edges", spec.name, virtual))
@@ -2555,20 +2687,45 @@ def setup(
         self.define_target_constraints()
 
     def literal_specs(self, specs):
-        for idx, spec in enumerate(specs):
+        for spec in specs:
             self.gen.h2("Spec: %s" % str(spec))
-            self.gen.fact(fn.literal(idx))
+            condition_id = next(self._condition_id_counter)
+            trigger_id = next(self._trigger_id_counter)
 
-            self.gen.fact(fn.literal(idx, "virtual_root" if spec.virtual else "root", spec.name))
-            for clause in self.spec_clauses(spec):
-                self.gen.fact(fn.literal(idx, *clause.args))
-                if clause.args[0] == "variant_set":
-                    self.gen.fact(
-                        fn.literal(idx, "variant_default_value_from_cli", *clause.args[1:])
+            # Special condition triggered by "literal_solved"
+            self.gen.fact(fn.literal(trigger_id))
+            self.gen.fact(fn.pkg_fact(spec.name, fn.condition_trigger(condition_id, trigger_id)))
+            self.gen.fact(fn.condition_reason(condition_id, f"{spec} requested from CLI"))
+
+            # Effect imposes the spec
+            imposed_spec_key = str(spec), None
+            cache = self._effect_cache[spec.name]
+            msg = (
+                "literal specs have different requirements. clear cache before computing literals"
+            )
+            assert imposed_spec_key not in cache, msg
+            effect_id = next(self._effect_id_counter)
+            requirements = self.spec_clauses(spec)
+            root_name = spec.name
+            for clause in requirements:
+                clause_name = clause.args[0]
+                if clause_name == "variant_set":
+                    requirements.append(
+                        fn.attr("variant_default_value_from_cli", *clause.args[1:])
                     )
+                elif clause_name in ("node", "virtual_node", "hash"):
+                    # These facts are needed to compute the "condition_set" of the root
+                    pkg_name = clause.args[1]
+                    self.gen.fact(fn.mentioned_in_literal(trigger_id, root_name, pkg_name))
+
+            requirements.append(fn.attr("virtual_root" if spec.virtual else "root", spec.name))
+            cache[imposed_spec_key] = (effect_id, requirements)
+            self.gen.fact(fn.pkg_fact(spec.name, fn.condition_effect(condition_id, effect_id)))
 
             if self.concretize_everything:
-                self.gen.fact(fn.solve_literal(idx))
+                self.gen.fact(fn.solve_literal(trigger_id))
+
+        self.effect_rules()
 
     def validate_and_define_versions_from_requirements(
         self, *, allow_deprecated: bool, require_checksum: bool
diff --git a/lib/spack/spack/util/parallel.py b/lib/spack/spack/util/parallel.py
--- a/lib/spack/spack/util/parallel.py
+++ b/lib/spack/spack/util/parallel.py
@@ -6,6 +6,7 @@
 import os
 import sys
 import traceback
+from typing import Optional
 
 
 class ErrorFromWorker:
@@ -53,7 +54,9 @@ def __call__(self, *args, **kwargs):
         return value
 
 
-def imap_unordered(f, list_of_args, *, processes: int, debug=False):
+def imap_unordered(
+    f, list_of_args, *, processes: int, maxtaskperchild: Optional[int] = None, debug=False
+):
     """Wrapper around multiprocessing.Pool.imap_unordered.
 
     Args:
@@ -62,6 +65,8 @@ def imap_unordered(f, list_of_args, *, processes: int, debug=False):
         processes: maximum number of processes allowed
         debug: if False, raise an exception containing just the error messages
             from workers, if True an exception with complete stacktraces
+        maxtaskperchild: number of tasks to be executed by a child before being
+            killed and substituted
 
     Raises:
         RuntimeError: if any error occurred in the worker processes
@@ -70,7 +75,7 @@ def imap_unordered(f, list_of_args, *, processes: int, debug=False):
         yield from map(f, list_of_args)
         return
 
-    with multiprocessing.Pool(processes) as p:
+    with multiprocessing.Pool(processes, maxtasksperchild=maxtaskperchild) as p:
         for result in p.imap_unordered(Task(f), list_of_args):
             if isinstance(result, ErrorFromWorker):
                 raise RuntimeError(result.stacktrace if debug else str(result))


Test patch:
diff --git a/lib/spack/spack/test/concretize_errors.py b/lib/spack/spack/test/concretize_errors.py
new file mode 100644
--- /dev/null
+++ b/lib/spack/spack/test/concretize_errors.py
@@ -0,0 +1,68 @@
+# Copyright 2013-2023 Lawrence Livermore National Security, LLC and other
+# Spack Project Developers. See the top-level COPYRIGHT file for details.
+#
+# SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+import pytest
+
+import spack.solver.asp
+import spack.spec
+
+pytestmark = [
+    pytest.mark.not_on_windows("Windows uses old concretizer"),
+    pytest.mark.only_clingo("Original concretizer does not support configuration requirements"),
+]
+
+version_error_messages = [
+    "Cannot satisfy 'fftw@:1.0' and 'fftw@1.1:",
+    "        required because quantum-espresso depends on fftw@:1.0",
+    "          required because quantum-espresso ^fftw@1.1: requested from CLI",
+    "        required because quantum-espresso ^fftw@1.1: requested from CLI",
+]
+
+external_error_messages = [
+    (
+        "Attempted to build package quantum-espresso which is not buildable and does not have"
+        " a satisfying external"
+    ),
+    (
+        "        'quantum-espresso~veritas' is an external constraint for quantum-espresso"
+        " which was not satisfied"
+    ),
+    "        'quantum-espresso+veritas' required",
+    "        required because quantum-espresso+veritas requested from CLI",
+]
+
+variant_error_messages = [
+    "'fftw' required multiple values for single-valued variant 'mpi'",
+    "    Requested '~mpi' and '+mpi'",
+    "        required because quantum-espresso depends on fftw+mpi when +invino",
+    "          required because quantum-espresso+invino ^fftw~mpi requested from CLI",
+    "        required because quantum-espresso+invino ^fftw~mpi requested from CLI",
+]
+
+external_config = {
+    "packages:quantum-espresso": {
+        "buildable": False,
+        "externals": [{"spec": "quantum-espresso@1.0~veritas", "prefix": "/path/to/qe"}],
+    }
+}
+
+
+@pytest.mark.parametrize(
+    "error_messages,config_set,spec",
+    [
+        (version_error_messages, {}, "quantum-espresso^fftw@1.1:"),
+        (external_error_messages, external_config, "quantum-espresso+veritas"),
+        (variant_error_messages, {}, "quantum-espresso+invino^fftw~mpi"),
+    ],
+)
+def test_error_messages(error_messages, config_set, spec, mock_packages, mutable_config):
+    for path, conf in config_set.items():
+        spack.config.set(path, conf)
+
+    with pytest.raises(spack.solver.asp.UnsatisfiableSpecError) as e:
+        _ = spack.spec.Spec(spec).concretized()
+
+    for em in error_messages:
+        assert em in str(e.value)