Repository: pantsbuild/pants

Issue:
Add `paths` goal to render one or all of the paths between two targets
In v1, the [`paths` goal](https://github.com/pantsbuild/pants/blob/1.30.x/src/python/pants/backend/graph_info/tasks/paths.py) was very useful for debugging whether two targets depended on one another.

Particularly to help with spot checking why particular things are dependencies in the context of inference, we should revive this in v2.


Discussion:
To do this, you'd probably:

0. Check out https://www.pantsbuild.org/docs/contributor-overview
1. Add a new goal named `paths` via https://www.pantsbuild.org/docs/rules-api-goal-rules, which would render a list of address ([`Address.spec`](https://github.com/pantsbuild/pants/blob/995448e9add343975844c7a43d5d64618fc4e4d9/src/python/pants/build_graph/address.py#L313-L321)) paths, probably as JSON
    * Can also start from an existing goal like [`dependencies`](https://github.com/pantsbuild/pants/blob/main/src/python/pants/backend/project_info/dependencies.py): at the very least, will want to define the new goal next to it.
2. In that new goal, take as arguments the same arguments as the `dependencies` goal:
    a. the `Console`: https://www.pantsbuild.org/docs/rules-api-goal-rules#console-output-to-stdoutstderr
    b. `Addresses`, which will correspond to the addresses matched on the command line
    c. the options `Subsystem` for your goal
3. Confirm that you received exactly two addresses (the root, and the destination) as inputs, and raise a useful exception if not
4. Compute the relevant adjacency lists:
    a. Start by requesting the transitive graph below the first address, via something like:
    ```python
    transitive_targets = await Get(
        TransitiveTargets, TransitiveTargetsRequest([root], include_special_cased_deps=True)
    )
    ```
    b. If the destination is not contained in the transitive deps of the root, exit immediately with an empty list of paths
    c. Otherwise, request the dependency adjacency lists for each target in the transitive set, via something like:
    ```python
    adjacent_targets_per_target = await MultiGet(
        Get(
            Targets,
            DependenciesRequest(tgt.get(DependenciesField), include_special_cased_deps=True),
        )
        for tgt in transitive_targets.closure()
    )
    adjacency_lists = dict(zip(transitive_targets.closure(), adjacent_targets_per_target))
    ```
    d. You now have a `dict[Target, Targets]` representing all of the potentially relevant adjacency lists.
5. Search the adjacency lists using the algorithm [from v1](https://github.com/pantsbuild/pants/blob/1.30.x/src/python/pants/backend/graph_info/tasks/paths.py), or any other (I can help with exposing a [Rust implementation](https://docs.rs/petgraph/0.6.0/petgraph/algo/simple_paths/fn.all_simple_paths.html) if need be). One consideration though is that the algorithm must be cycle-tolerant.
6. Render all paths as JSON, effectively as a `list[list[str]]`.
7. Copy an existing test such as [`dependees_test.py`](https://github.com/pantsbuild/pants/blob/main/src/python/pants/backend/project_info/dependees_test.py), and adjust all the references to your new goal.

Good luck!

Resolving patch:
diff --git a/src/python/pants/backend/project_info/paths.py b/src/python/pants/backend/project_info/paths.py
new file mode 100644
--- /dev/null
+++ b/src/python/pants/backend/project_info/paths.py
@@ -0,0 +1,141 @@
+# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
+# Licensed under the Apache License, Version 2.0 (see LICENSE).
+
+from __future__ import annotations
+
+import json
+from collections import deque
+from typing import Iterable, cast
+
+from pants.engine.addresses import Address, Addresses, UnparsedAddressInputs
+from pants.engine.console import Console
+from pants.engine.goal import Goal, GoalSubsystem, Outputting
+from pants.engine.rules import Get, MultiGet, collect_rules, goal_rule
+from pants.engine.target import Dependencies as DependenciesField
+from pants.engine.target import (
+    DependenciesRequest,
+    Targets,
+    TransitiveTargets,
+    TransitiveTargetsRequest,
+)
+
+
+class PathsSubsystem(Outputting, GoalSubsystem):
+    name = "paths"
+    help = "List the paths between two addresses."
+
+    @classmethod
+    def register_options(cls, register):
+        super().register_options(register)
+        register(
+            "--from",
+            type=str,
+            dest="frm",
+            help="The path starting address",
+        )
+
+        register(
+            "--to",
+            type=str,
+            help="The path end address",
+        )
+
+    @property
+    def path_from(self) -> str:
+        return cast(str, self.options.frm)
+
+    @property
+    def path_to(self) -> str:
+        return cast(str, self.options.to)
+
+
+class PathsGoal(Goal):
+    subsystem_cls = PathsSubsystem
+
+
+def find_paths_breadth_first(
+    adjacency_lists: dict[Address, Targets], from_target: Address, to_target: Address
+) -> Iterable[list[Address]]:
+    """Yields the paths between from_target to to_target if they exist.
+
+    The paths are returned ordered by length, shortest first. If there are cycles, it checks visited
+    edges to prevent recrossing them.
+    """
+
+    if from_target == to_target:
+        yield [from_target]
+        return
+
+    visited_edges = set()
+    to_walk_paths = deque([[from_target]])
+
+    while len(to_walk_paths) > 0:
+        cur_path = to_walk_paths.popleft()
+        target = cur_path[-1]
+
+        if len(cur_path) > 1:
+            prev_target: Address | None = cur_path[-2]
+        else:
+            prev_target = None
+        current_edge = (prev_target, target)
+
+        if current_edge not in visited_edges:
+            for dep in adjacency_lists[target]:
+                dep_path = cur_path + [dep.address]
+                if dep.address == to_target:
+                    yield dep_path
+                else:
+                    to_walk_paths.append(dep_path)
+            visited_edges.add(current_edge)
+
+
+@goal_rule
+async def paths(
+    console: Console, addresses: Addresses, paths_subsystem: PathsSubsystem
+) -> PathsGoal:
+
+    path_from = paths_subsystem.path_from
+    path_to = paths_subsystem.path_to
+
+    if path_from is None:
+        raise ValueError("Must set a --paths-from")
+
+    if path_to is None:
+        raise ValueError("Must set a --paths-to")
+
+    root, destination = await Get(
+        Addresses,
+        UnparsedAddressInputs(values=[path_from, path_to], owning_address=None),
+    )
+
+    transitive_targets = await Get(
+        TransitiveTargets, TransitiveTargetsRequest([root], include_special_cased_deps=True)
+    )
+
+    if not any(destination == dep.address for dep in transitive_targets.closure):
+        raise ValueError("The destination is not a dependency of the source")
+
+    adjacent_targets_per_target = await MultiGet(
+        Get(
+            Targets,
+            DependenciesRequest(tgt.get(DependenciesField), include_special_cased_deps=True),
+        )
+        for tgt in transitive_targets.closure
+    )
+
+    transitive_targets_closure_addresses = (t.address for t in transitive_targets.closure)
+    adjacency_lists = dict(zip(transitive_targets_closure_addresses, adjacent_targets_per_target))
+
+    spec_paths = []
+    for path in find_paths_breadth_first(adjacency_lists, root, destination):
+        spec_path = [address.spec for address in path]
+        spec_paths.append(spec_path)
+
+    with paths_subsystem.output(console) as write_stdout:
+        write_stdout(json.dumps(spec_paths, indent=2))
+
+    return PathsGoal(exit_code=0)
+
+
+def rules():
+    return collect_rules()
diff --git a/src/python/pants/backend/project_info/register.py b/src/python/pants/backend/project_info/register.py
--- a/src/python/pants/backend/project_info/register.py
+++ b/src/python/pants/backend/project_info/register.py
@@ -11,6 +11,7 @@
     filter_targets,
     list_roots,
     list_targets,
+    paths,
     peek,
     source_file_validator,
 )
@@ -25,6 +26,7 @@ def rules():
         *filter_targets.rules(),
         *list_roots.rules(),
         *list_targets.rules(),
+        *paths.rules(),
         *peek.rules(),
         *source_file_validator.rules(),
     ]


Test patch:
diff --git a/src/python/pants/backend/project_info/paths_test.py b/src/python/pants/backend/project_info/paths_test.py
new file mode 100644
--- /dev/null
+++ b/src/python/pants/backend/project_info/paths_test.py
@@ -0,0 +1,83 @@
+# Copyright 2020 Pants project contributors (see CONTRIBUTORS.md).
+# Licensed under the Apache License, Version 2.0 (see LICENSE).
+import json
+from typing import List
+
+import pytest
+
+from pants.backend.project_info.paths import PathsGoal
+from pants.backend.project_info.paths import rules as paths_rules
+from pants.engine.internals.scheduler import ExecutionError
+from pants.engine.target import Dependencies, Target
+from pants.testutil.rule_runner import RuleRunner
+
+
+class MockTarget(Target):
+    alias = "tgt"
+    core_fields = (Dependencies,)
+
+
+@pytest.fixture
+def rule_runner() -> RuleRunner:
+    runner = RuleRunner(rules=paths_rules(), target_types=[MockTarget])
+    runner.add_to_build_file("base", "tgt()")
+    runner.add_to_build_file("intermediate", "tgt(dependencies=['base'])")
+    runner.add_to_build_file("intermediate2", "tgt(dependencies=['base'])")
+    runner.add_to_build_file("leaf", "tgt(dependencies=['intermediate', 'intermediate2'])")
+    return runner
+
+
+def assert_paths(
+    rule_runner: RuleRunner,
+    *,
+    path_from: str,
+    path_to: str,
+    expected: List[List[str]],
+) -> None:
+    args = []
+    if path_from:
+        args += [f"--paths-from={path_from}"]
+    if path_to:
+        args += [f"--paths-to={path_to}"]
+
+    result = rule_runner.run_goal_rule(PathsGoal, args=[*args])
+
+    assert sorted(json.loads(result.stdout)) == sorted(expected)
+
+
+@pytest.mark.parametrize(
+    "path_from,path_to", [["", ""], ["intermediate:intermediate", ""], ["", "base:base"]]
+)
+def test_no_targets(rule_runner: RuleRunner, path_from: str, path_to: str) -> None:
+    with pytest.raises(ExecutionError):
+        assert_paths(rule_runner, path_from=path_from, path_to=path_to, expected=[])
+
+
+def test_normal(rule_runner: RuleRunner) -> None:
+    assert_paths(
+        rule_runner,
+        path_from="intermediate:intermediate",
+        path_to="base:base",
+        expected=[["intermediate:intermediate", "base:base"]],
+    )
+
+
+def test_path_to_self(rule_runner: RuleRunner) -> None:
+    assert_paths(
+        rule_runner,
+        path_from="base:base",
+        path_to="base:base",
+        expected=[["base:base"]],
+    )
+
+
+def test_multiple_paths(rule_runner: RuleRunner) -> None:
+    assert_paths(
+        rule_runner,
+        path_from="leaf:leaf",
+        path_to="base:base",
+        expected=[
+            ["leaf:leaf", "intermediate:intermediate", "base:base"],
+            ["leaf:leaf", "intermediate2:intermediate2", "base:base"],
+        ],
+    )