Repository: spack/spack

Issue:
Exclude/include rules are not always applied when filtering reused specs
### Steps to reproduce

When concretizing the following environment
```yaml
spack:
  specs:
  - libvterm
  view: true
  concretizer:
    unify: true
    reuse:
      from:
      - type: buildcache
        exclude:
        - libtool
  mirrors:
    develop-ml-linux-x86_64-cpu: https://binaries.spack.io/develop/ml-linux-x86_64-cpu
```
using the following command:
```console
$ spack concretize
```
`libtool` gets reused from the buildcache, even if it is explicitly excluded from the configuration.

### Error message

No error message, but a configuration entry is not respected. The issue is with the way we handle reuse, which traverses dependencies and adds back the excluded spec _iff_ it is a dependency of something not excluded.

### Information on your system

* **Spack:** 0.22.0.dev0 (fd3cd3a1c6a7147c1a84d6a8415800c8f079c5c6)
* **Python:** 3.11.5
* **Platform:** linux-ubuntu20.04-icelake
* **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/solver/asp.py b/lib/spack/spack/solver/asp.py
--- a/lib/spack/spack/solver/asp.py
+++ b/lib/spack/spack/solver/asp.py
@@ -944,14 +944,26 @@ class ConcreteSpecsByHash(collections.abc.Mapping):
 
     def __init__(self) -> None:
         self.data: Dict[str, spack.spec.Spec] = {}
+        self.explicit: Set[str] = set()
 
     def __getitem__(self, dag_hash: str) -> spack.spec.Spec:
         return self.data[dag_hash]
 
+    def explicit_items(self) -> Iterator[Tuple[str, spack.spec.Spec]]:
+        """Iterate on items that have been added explicitly, and not just as a dependency
+        of other nodes.
+        """
+        for h, s in self.items():
+            # We need to make an exception for gcc-runtime, until we can splice it.
+            if h in self.explicit or s.name == "gcc-runtime":
+                yield h, s
+
     def add(self, spec: spack.spec.Spec) -> bool:
         """Adds a new concrete spec to the mapping. Returns True if the spec was just added,
         False if the spec was already in the mapping.
 
+        Calling this function marks the spec as added explicitly.
+
         Args:
             spec: spec to be added
 
@@ -966,6 +978,7 @@ def add(self, spec: spack.spec.Spec) -> bool:
             raise ValueError(msg)
 
         dag_hash = spec.dag_hash()
+        self.explicit.add(dag_hash)
         if dag_hash in self.data:
             return False
 
@@ -2349,7 +2362,7 @@ def register_concrete_spec(self, spec, possible):
 
     def concrete_specs(self):
         """Emit facts for reusable specs"""
-        for h, spec in self.reusable_and_possible.items():
+        for h, spec in self.reusable_and_possible.explicit_items():
             # this indicates that there is a spec like this installed
             self.gen.fact(fn.installed_hash(spec.name, h))
             # this describes what constraints it imposes on the solve


Test patch:
diff --git a/lib/spack/spack/test/concretize.py b/lib/spack/spack/test/concretize.py
--- a/lib/spack/spack/test/concretize.py
+++ b/lib/spack/spack/test/concretize.py
@@ -2463,6 +2463,46 @@ def test_spec_with_build_dep_from_json(self, tmp_path):
         s = Spec(f"dtuse ^{str(json_file)}").concretized()
         assert s["dttop"].dag_hash() == build_dep.dag_hash()
 
+    @pytest.mark.regression("44040")
+    def test_exclude_specs_from_reuse(self, monkeypatch):
+        """Tests that we can exclude a spec from reuse when concretizing, and that the spec
+        is not added back to the solve as a dependency of another reusable spec.
+
+        The expected spec is:
+
+        o callpath@1.0
+        |\
+        | |\
+        o | | mpich@3.0.4
+        |/ /
+        | o dyninst@8.2
+        |/|
+        | |\
+        | | o libdwarf@20130729
+        | |/|
+        |/|/
+        | o libelf@0.8.13
+        |/
+        o glibc@2.31
+        """
+        # Prepare a mock mirror that returns an old version of dyninst
+        request_str = "callpath ^mpich"
+        reused = Spec(f"{request_str} ^dyninst@8.1.1").concretized()
+        monkeypatch.setattr(spack.solver.asp, "_specs_from_mirror", lambda: [reused])
+
+        # Exclude dyninst from reuse, so we expect that the old version is not taken into account
+        with spack.config.override(
+            "concretizer:reuse", {"from": [{"type": "buildcache", "exclude": ["dyninst"]}]}
+        ):
+            result = Spec(request_str).concretized()
+
+        assert result.dag_hash() != reused.dag_hash()
+        assert result["mpich"].dag_hash() == reused["mpich"].dag_hash()
+        assert result["dyninst"].dag_hash() != reused["dyninst"].dag_hash()
+        assert result["dyninst"].satisfies("@=8.2")
+        for dep in result["dyninst"].traverse(root=False):
+            assert dep.dag_hash() == reused[dep.name].dag_hash()
+
 
 @pytest.fixture()
 def duplicates_test_repository():