Repository: pantsbuild/pants

Issue:
More succinct rule graph errors
Currently if a rule Foo tries to `Get` something that isn't produced (for the given params) by any rule, we display an error. So far so good. 

However we also display an error for any rule Bar that tries to `Get` the thing Foo produces, and so on, transitively.  

We should probably only display an error for the innermost problem, since fixing that will fix the transitive problems. 

This is a little complicated, because there could be multiple problems, and it's not clear to me that it's always possible to tell the difference between a "root error" and a "knock-on error". Still, worth looking into.


Discussion:
I think this is a great idea!

Resolving patch:
diff --git a/src/python/pants/testutil/engine/util.py b/src/python/pants/testutil/engine/util.py
--- a/src/python/pants/testutil/engine/util.py
+++ b/src/python/pants/testutil/engine/util.py
@@ -298,19 +298,28 @@ def fmt_rust_function(func: Callable) -> str:
     return f"{func.__module__}:{func.__code__.co_firstlineno}:{func.__name__}"
 
 
-def fmt_rule(rule: Callable, *, gets: Optional[List[Tuple[str, str]]] = None) -> str:
+def fmt_rule(
+    rule: Callable, *, gets: Optional[List[Tuple[str, str]]] = None, multiline: bool = False
+) -> str:
     """Generate the str that the engine will use for the rule.
 
-    This is useful when comparing strings against engine error messages.
+    This is useful when comparing strings against engine error messages. Emulates the implementation
+    of the DisplayForGraph trait.
     """
+    line_sep = "\n" if multiline else " "
+    optional_line_sep = "\n" if multiline else ""
+
     type_hints = get_type_hints(rule)
     product = type_hints.pop("return").__name__
-    params = ", ".join(t.__name__ for t in type_hints.values())
+    params = f",{line_sep}".join(t.__name__ for t in type_hints.values())
+    params_str = (
+        f"{optional_line_sep}{params}{optional_line_sep}" if len(type_hints) > 1 else params
+    )
     gets_str = ""
     if gets:
-        get_members = ", ".join(
+        get_members = f",{line_sep}".join(
             f"Get({product_subject_pair[0]}, {product_subject_pair[1]})"
             for product_subject_pair in gets
         )
-        gets_str = f", gets=[{get_members}]"
-    return f"@rule({fmt_rust_function(rule)}({params}) -> {product}{gets_str})"
+        gets_str = f", gets=[{optional_line_sep}{get_members}{optional_line_sep}]"
+    return f"@rule({fmt_rust_function(rule)}({params_str}) -> {product}{gets_str})"


Test patch:
diff --git a/src/python/pants/engine/internals/engine_test.py b/src/python/pants/engine/internals/engine_test.py
--- a/src/python/pants/engine/internals/engine_test.py
+++ b/src/python/pants/engine/internals/engine_test.py
@@ -19,11 +19,7 @@
     StreamingWorkunitContext,
     StreamingWorkunitHandler,
 )
-from pants.testutil.engine.util import (
-    assert_equal_with_printing,
-    fmt_rule,
-    remove_locations_from_traceback,
-)
+from pants.testutil.engine.util import assert_equal_with_printing, remove_locations_from_traceback
 from pants.testutil.test_base import TestBase
 from pants.util.logging import LogLevel
 
@@ -325,17 +321,7 @@ def test_nonexistent_root_fails_differently(self):
         with self.assertRaises(Exception) as cm:
             list(self.mk_scheduler(rules=rules, include_trace_on_error=False))
 
-        self.assert_equal_with_printing(
-            dedent(
-                f"""
-                Rules with errors: 1
-
-                  {fmt_rule(upcast)}:
-                    No rule was available to compute MyInt. Maybe declare RootRule(MyInt)?
-                """
-            ).strip(),
-            str(cm.exception),
-        )
+        assert "consider declaring RootRule(MyInt)" in str(cm.exception)
 
 
 @dataclass
diff --git a/src/python/pants/engine/rules_test.py b/src/python/pants/engine/rules_test.py
--- a/src/python/pants/engine/rules_test.py
+++ b/src/python/pants/engine/rules_test.py
@@ -7,7 +7,7 @@
 from dataclasses import dataclass
 from enum import Enum
 from textwrap import dedent
-from typing import Callable, List, Optional, Tuple, Type, Union, get_type_hints
+from typing import Callable, List, Optional, Tuple, Type, Union
 
 import pytest
 
@@ -29,7 +29,6 @@
     assert_equal_with_printing,
     create_scheduler,
     fmt_rule,
-    fmt_rust_function,
     run_rule,
 )
 from pants.testutil.test_base import TestBase
@@ -152,20 +151,6 @@ def test_invalid_get_invalid_product_type_not_a_type_name(self) -> None:
             self._parse_rule_gets("Get(call(), A('bob'))", A=str)
 
 
-def fmt_graph_rule(rule: Callable, *, gets: Optional[List[Tuple[str, str]]] = None) -> str:
-    type_hints = get_type_hints(rule)
-    product = type_hints.pop("return").__name__
-    params = ", ".join(t.__name__ for t in type_hints.values())
-    gets_str = ""
-    if gets:
-        get_members = ",\n".join(
-            f"Get({product_subject_pair[0]}, {product_subject_pair[1]})"
-            for product_subject_pair in gets
-        )
-        gets_str = f",\ngets=[{get_members}]"
-    return f"@rule({params}) -> {product}{gets_str}\n{fmt_rust_function(rule)}"
-
-
 @dataclass(frozen=True)
 class RuleFormatRequest:
     rule: Callable
@@ -173,7 +158,7 @@ class RuleFormatRequest:
     gets: Optional[List[Tuple[str, str]]] = None
 
     def format(self) -> str:
-        msg = fmt_graph_rule(self.rule, gets=self.gets)
+        msg = fmt_rule(self.rule, gets=self.gets, multiline=True)
         if self.for_param is not None:
             if isinstance(self.for_param, type):
                 msg += f"\nfor {self.for_param.__name__}"
@@ -190,7 +175,7 @@ def format_rule(cls, obj):
             return obj.format()
         if isinstance(obj, type):
             return f"Select({obj.__name__})"
-        return fmt_graph_rule(obj)
+        return fmt_rule(obj, multiline=True)
 
 
 def fmt_param_edge(
@@ -517,7 +502,7 @@ def a_from_b(b: B) -> A:
                 Rules with errors: 1
 
                   {fmt_rule(a_from_b)}:
-                    No rule was available to compute B with parameter type SubA
+                    No rule was able to compute B. No installed rules return the type B: Is the rule that you're expecting to run registered? If that type should be provided from outside the rule graph, consider declaring RootRule(B).
                 """
             ).strip(),
             str(cm.exception),
@@ -561,10 +546,8 @@ def d_from_a(a: A) -> D:
 
                   {fmt_rule(d_from_a)}:
                     Ambiguous rules to compute A with parameter types (B, C):
-                      {fmt_graph_rule(a_from_b_and_c)}
-                for (B, C)
-                      {fmt_graph_rule(a_from_c_and_b)}
-                for (B, C)
+                      {fmt_rule(a_from_b_and_c)} for (B, C)
+                      {fmt_rule(a_from_c_and_b)} for (B, C)
                 """
             ),
             str(cm.exception),
@@ -585,8 +568,8 @@ def a_from_b_and_c(b: B, c: C) -> A:
                 Rules with errors: 1
 
                   {fmt_rule(a_from_b_and_c)}:
-                    No rule was available to compute B with parameter type SubA
-                    No rule was available to compute C with parameter type SubA
+                    No rule was able to compute B. No installed rules return the type B: Is the rule that you're expecting to run registered? If that type should be provided from outside the rule graph, consider declaring RootRule(B).
+                    No rule was able to compute C. No installed rules return the type C: Is the rule that you're expecting to run registered? If that type should be provided from outside the rule graph, consider declaring RootRule(C).
                 """
             ).strip(),
             str(cm.exception),
@@ -620,13 +603,10 @@ def b_from_suba(suba: SubA) -> B:
         self.assert_equal_with_printing(
             dedent(
                 f"""\
-                Rules with errors: 2
-
-                  {fmt_rule(a_from_b)}:
-                    No rule was available to compute B with parameter type C
+                Rules with errors: 1
 
                   {fmt_rule(b_from_suba)}:
-                    No rule was available to compute SubA with parameter type C
+                    No rule was able to compute SubA. No installed rules return the type SubA: Is the rule that you're expecting to run registered? If that type should be provided from outside the rule graph, consider declaring RootRule(SubA).
                 """
             ).strip(),
             str(cm.exception),
@@ -657,7 +637,7 @@ def b_singleton() -> B:
                 Rules with errors: 1
 
                   {fmt_rule(d_from_c)}:
-                    No rule was available to compute C with parameter type A
+                    No rule was able to compute C. No installed rules return the type C: Is the rule that you're expecting to run registered? If that type should be provided from outside the rule graph, consider declaring RootRule(C).
                 """
             ).strip(),
             str(cm.exception),
@@ -692,16 +672,14 @@ async def d_from_a_and_suba(a: A, suba: SubA) -> D:  # type: ignore[return]
             self,
             dedent(
                 f"""\
-                Rules with errors: 3
+                Rules with errors: 2
 
                   {fmt_rule(a_from_c)}:
                     Was not reachable, either because no rules could produce the params or because it was shadowed by another @rule.
-                  {fmt_rule(b_from_d)}:
 
-                    No rule was available to compute D with parameter type SubA
                   {fmt_rule(d_from_a_and_suba, gets=[("A", "C")])}:
-
-                    No rule was available to compute A with parameter type SubA
+                    No rule was able to compute A.:
+                      {fmt_rule(a_from_c)} for SubA: Was unfulfillable.
                 """
             ).strip(),
             str(cm.exception),
diff --git a/src/rust/engine/rule_graph/src/tests.rs b/src/rust/engine/rule_graph/src/tests.rs
--- a/src/rust/engine/rule_graph/src/tests.rs
+++ b/src/rust/engine/rule_graph/src/tests.rs
@@ -24,7 +24,7 @@ fn no_root() {
     .validate()
     .err()
     .unwrap()
-    .contains("No rule was available to compute DependencyKey(\"b\", None)."));
+    .contains("No rule was able to compute DependencyKey(\"b\", None)."));
 }
 
 #[test]
@@ -113,7 +113,7 @@ impl super::Rule for Rule {
 }
 
 impl super::DisplayForGraph for Rule {
-  fn fmt_for_graph(&self) -> String {
+  fn fmt_for_graph(&self, _: super::DisplayForGraphArgs) -> String {
     self.to_string()
   }
 }