Add proto-output-format flag for adding $internal_attr_hash

$internal_attr_hash is a magic attribute which can be used to tell if a rule
has changed in a potentially meaningful way across queries. The name itself
is a slight misnomer (non-attribute properties can contribute to the hash),
but it is what it is for historical reasons.

Note that the postProcess call removed from ConfiguredProtoOutputFormatter was
always a noop.

RELNOTES: None
PiperOrigin-RevId: 300146650
diff --git a/src/main/java/com/google/devtools/build/lib/query2/common/CommonQueryOptions.java b/src/main/java/com/google/devtools/build/lib/query2/common/CommonQueryOptions.java
index 878461c..84fcbd3 100644
--- a/src/main/java/com/google/devtools/build/lib/query2/common/CommonQueryOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/query2/common/CommonQueryOptions.java
@@ -172,6 +172,14 @@
       help = "Whether or not to populate the rule_input and rule_output fields.")
   public boolean protoIncludeRuleInputsAndOutputs;
 
+  @Option(
+      name = "proto:include_synthetic_attribute_hash",
+      defaultValue = "false",
+      documentationCategory = OptionDocumentationCategory.QUERY,
+      effectTags = {OptionEffectTag.TERMINAL_OUTPUT},
+      help = "Whether or not to calculate and populate the $internal_attr_hash attribute.")
+  public boolean protoIncludeSyntheticAttributeHash;
+
   /** An enum converter for {@code AspectResolver.Mode} . Should be used internally only. */
   public static class AspectResolutionModeConverter extends EnumConverter<Mode> {
     public AspectResolutionModeConverter() {
diff --git a/src/main/java/com/google/devtools/build/lib/query2/cquery/ProtoOutputFormatterCallback.java b/src/main/java/com/google/devtools/build/lib/query2/cquery/ProtoOutputFormatterCallback.java
index cc79c8b..fd7e54f 100644
--- a/src/main/java/com/google/devtools/build/lib/query2/cquery/ProtoOutputFormatterCallback.java
+++ b/src/main/java/com/google/devtools/build/lib/query2/cquery/ProtoOutputFormatterCallback.java
@@ -159,8 +159,7 @@
   private class ConfiguredProtoOutputFormatter extends ProtoOutputFormatter {
     @Override
     protected void addAttributes(
-        Build.Rule.Builder rulePb, Rule rule, Object extraDataForPostProcess)
-        throws InterruptedException {
+        Build.Rule.Builder rulePb, Rule rule, Object extraDataForAttrHash) {
       // We know <code>currentTarget</code> will be one of these two types of configured targets
       // because this method is only triggered in ProtoOutputFormatter.toTargetProtoBuffer when
       // the target in currentTarget is an instanceof Rule.
@@ -190,7 +189,6 @@
         serializedAttributes.put(attr, serializedAttribute);
       }
       rulePb.addAllAttribute(serializedAttributes.values());
-      postProcess(rule, rulePb, serializedAttributes, extraDataForPostProcess);
     }
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/query2/query/output/BUILD b/src/main/java/com/google/devtools/build/lib/query2/query/output/BUILD
index 711dd31..bab3465 100644
--- a/src/main/java/com/google/devtools/build/lib/query2/query/output/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/query2/query/output/BUILD
@@ -24,9 +24,11 @@
         "//src/main/java/com/google/devtools/build/lib/query2/compat:fake-load-target",
         "//src/main/java/com/google/devtools/build/lib/query2/engine",
         "//src/main/java/com/google/devtools/build/lib/query2/query/aspectresolvers",
+        "//src/main/java/com/google/devtools/build/lib/vfs",
         "//src/main/java/com/google/devtools/build/lib/vfs:pathfragment",
         "//src/main/java/com/google/devtools/common/options",
         "//src/main/protobuf:build_java_proto",
+        "//third_party:flogger",
         "//third_party:guava",
         "//third_party:jsr305",
         "//third_party/protobuf:protobuf_java",
diff --git a/src/main/java/com/google/devtools/build/lib/query2/query/output/ProtoOutputFormatter.java b/src/main/java/com/google/devtools/build/lib/query2/query/output/ProtoOutputFormatter.java
index 23db2a6..0a78721 100644
--- a/src/main/java/com/google/devtools/build/lib/query2/query/output/ProtoOutputFormatter.java
+++ b/src/main/java/com/google/devtools/build/lib/query2/query/output/ProtoOutputFormatter.java
@@ -51,6 +51,7 @@
 import com.google.devtools.build.lib.query2.engine.SynchronizedDelegatingOutputFormatterCallback;
 import com.google.devtools.build.lib.query2.engine.ThreadSafeOutputFormatterCallback;
 import com.google.devtools.build.lib.query2.proto.proto2api.Build;
+import com.google.devtools.build.lib.query2.proto.proto2api.Build.Attribute.Discriminator;
 import com.google.devtools.build.lib.query2.proto.proto2api.Build.GeneratedFile;
 import com.google.devtools.build.lib.query2.proto.proto2api.Build.QueryResult;
 import com.google.devtools.build.lib.query2.proto.proto2api.Build.SourceFile;
@@ -97,6 +98,7 @@
   private boolean flattenSelects = true;
   private boolean includeLocations = true;
   private boolean includeRuleInputsAndOutputs = true;
+  private boolean includeSyntheticAttributeHash = false;
 
   @Override
   public String getName() {
@@ -114,6 +116,7 @@
     this.flattenSelects = options.protoFlattenSelects;
     this.includeLocations = options.protoIncludeLocations;
     this.includeRuleInputsAndOutputs = options.protoIncludeRuleInputsAndOutputs;
+    this.includeSyntheticAttributeHash = options.protoIncludeSyntheticAttributeHash;
   }
 
   private static Predicate<String> newAttributePredicate(List<String> outputAttributes) {
@@ -157,12 +160,12 @@
 
   /** Converts a logical {@link Target} object into a {@link Build.Target} protobuffer. */
   public Build.Target toTargetProtoBuffer(Target target) throws InterruptedException {
-    return toTargetProtoBuffer(target, null);
+    return toTargetProtoBuffer(target, /*extraDataForAttrHash=*/ "");
   }
 
   /** Converts a logical {@link Target} object into a {@link Build.Target} protobuffer. */
   @VisibleForTesting
-  public Build.Target toTargetProtoBuffer(Target target, Object extraDataForPostProcess)
+  public Build.Target toTargetProtoBuffer(Target target, Object extraDataForAttrHash)
       throws InterruptedException {
     Build.Target.Builder targetPb = Build.Target.newBuilder();
 
@@ -174,7 +177,7 @@
       if (includeLocations) {
         rulePb.setLocation(FormatUtils.getLocation(target, relativeLocations));
       }
-      addAttributes(rulePb, rule, extraDataForPostProcess);
+      addAttributes(rulePb, rule, extraDataForAttrHash);
       String transitiveHashCode = rule.getRuleClassObject().getRuleDefinitionEnvironmentHashCode();
       if (transitiveHashCode != null && includeRuleDefinitionEnvironment()) {
         // The RuleDefinitionEnvironment is always defined for Skylark rules and
@@ -329,7 +332,7 @@
     return targetPb.build();
   }
 
-  protected void addAttributes(Build.Rule.Builder rulePb, Rule rule, Object extraDataForPostProcess)
+  protected void addAttributes(Build.Rule.Builder rulePb, Rule rule, Object extraDataForAttrHash)
       throws InterruptedException {
     Map<Attribute, Build.Attribute> serializedAttributes = Maps.newHashMap();
     AggregatingAttributeMapper attributeMapper = AggregatingAttributeMapper.of(rule);
@@ -361,7 +364,15 @@
             .sorted(ATTRIBUTE_NAME)
             .collect(Collectors.toList()));
 
-    postProcess(rule, rulePb, serializedAttributes, extraDataForPostProcess);
+    if (includeSyntheticAttributeHash()) {
+      rulePb.addAttribute(
+          Build.Attribute.newBuilder()
+              .setName("$internal_attr_hash")
+              .setStringValue(
+                  SyntheticAttributeHashCalculator.compute(
+                      rule, serializedAttributes, extraDataForAttrHash))
+              .setType(Discriminator.STRING));
+    }
   }
 
   protected boolean shouldIncludeAttribute(Rule rule, Attribute attr) {
@@ -385,13 +396,6 @@
     }
   }
 
-  /** Further customize the proto output */
-  protected void postProcess(
-      Rule rule,
-      Build.Rule.Builder rulePb,
-      Map<Attribute, Build.Attribute> serializedAttributes,
-      Object extraDataForPostProcess) {}
-
   /** Allow filtering of aspect attributes. */
   protected boolean includeAspectAttribute(Attribute attr, Collection<Label> value) {
     return true;
@@ -401,6 +405,11 @@
     return true;
   }
 
+  /** Returns if the "$internal_attr_hash" should be computed and added to the result. */
+  protected boolean includeSyntheticAttributeHash() {
+    return includeSyntheticAttributeHash;
+  }
+
   /**
    * Coerces the list {@param possibleValues} of values of type {@param attrType} to a single
    * value of that type, in the following way:
diff --git a/src/main/java/com/google/devtools/build/lib/query2/query/output/SyntheticAttributeHashCalculator.java b/src/main/java/com/google/devtools/build/lib/query2/query/output/SyntheticAttributeHashCalculator.java
new file mode 100644
index 0000000..d0a7008
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/query2/query/output/SyntheticAttributeHashCalculator.java
@@ -0,0 +1,158 @@
+// Copyright 2020 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.devtools.build.lib.query2.query.output;
+
+import com.google.common.base.Preconditions;
+import com.google.common.flogger.GoogleLogger;
+import com.google.common.hash.HashingOutputStream;
+import com.google.common.io.BaseEncoding;
+import com.google.common.io.ByteStreams;
+import com.google.devtools.build.lib.packages.Attribute;
+import com.google.devtools.build.lib.packages.Attribute.ComputedDefault;
+import com.google.devtools.build.lib.packages.AttributeFormatter;
+import com.google.devtools.build.lib.packages.BuildType.SelectorList;
+import com.google.devtools.build.lib.packages.RawAttributeMapper;
+import com.google.devtools.build.lib.packages.Rule;
+import com.google.devtools.build.lib.packages.RuleClass;
+import com.google.devtools.build.lib.query2.proto.proto2api.Build;
+import com.google.devtools.build.lib.vfs.DigestHashFunction;
+import com.google.protobuf.CodedOutputStream;
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * Contains the logic for condensing the various properties of rules that contribute to their
+ * "affectedness" into a simple hash value. The resulting hash may be compared across queries to
+ * tell if a rule has changed in a potentially meaningful way.
+ */
+class SyntheticAttributeHashCalculator {
+
+  private static final GoogleLogger logger = GoogleLogger.forEnclosingClass();
+
+  private SyntheticAttributeHashCalculator() {}
+
+  /**
+   * Returns a hash of various properties of a rule which might contribute to the rule's
+   * "affectedness". This includes, but is not limited to, attribute values and error-state.
+   *
+   * @param rule The rule instance to calculate the hash for.
+   * @param serializedAttributes Any available attribute which have already been serialized. This is
+   *     an optimization to avoid re-serializing attributes internally.
+   * @param extraDataForAttrHash Extra data to add to the hash.
+   */
+  static String compute(
+      Rule rule,
+      Map<Attribute, Build.Attribute> serializedAttributes,
+      Object extraDataForAttrHash) {
+    HashingOutputStream hashingOutputStream =
+        new HashingOutputStream(
+            DigestHashFunction.getDefaultUnchecked().getHashFunction(),
+            ByteStreams.nullOutputStream());
+    CodedOutputStream codedOut = CodedOutputStream.newInstance(hashingOutputStream);
+
+    RuleClass ruleClass = rule.getRuleClassObject();
+    if (ruleClass.isSkylark()) {
+      try {
+        codedOut.writeStringNoTag(
+            Preconditions.checkNotNull(ruleClass.getRuleDefinitionEnvironmentHashCode(), rule));
+      } catch (IOException e) {
+        throw new IllegalStateException("Unexpected IO failure writing to digest stream", e);
+      }
+    }
+
+    RawAttributeMapper rawAttributeMapper = RawAttributeMapper.of(rule);
+    for (Attribute attr : rule.getAttributes()) {
+      String attrName = attr.getName();
+
+      if (attrName.equals("generator_location")) {
+        // generator_location can be ignored for the purpose of telling if a rule has changed.
+        continue;
+      }
+
+      Object valueToHash = rawAttributeMapper.getRawAttributeValue(rule, attr);
+
+      if (valueToHash instanceof ComputedDefault) {
+        // ConfiguredDefaults need special handling to detect changes in evaluated values.
+        ComputedDefault computedDefault = (ComputedDefault) valueToHash;
+        if (!computedDefault.dependencies().isEmpty()) {
+          // TODO(b/29038463): We're skipping computed defaults that depend on other configurable
+          // attributes because there currently isn't a way to evaluate such a computed default;
+          // there isn't *one* value it evaluates to.
+          continue;
+        }
+
+        try {
+          valueToHash = computedDefault.getDefault(rawAttributeMapper);
+        } catch (IllegalArgumentException e) {
+          // TODO(mschaller): Catching IllegalArgumentException isn't ideal. It's thrown by
+          // AbstractAttributeMapper#get if the attribute's type doesn't match its value, which
+          // would happen if a ComputedDefault function accessed an attribute whose value was
+          // configurable. We check whether the ComputedDefault declared any configurable
+          // attribute dependencies above, but someone could make a mistake and fail to declare
+          // something. There's no mechanism that enforces correct declaration right now.
+          // This allows us to recover from such an error by skipping an attribute, as opposed to
+          // crashing.
+          logger.atWarning().log(
+              "Recovering from failed evaluation of ComputedDefault attribute value: " + e);
+          continue;
+        }
+      }
+
+      Build.Attribute attrPb;
+      if (valueToHash instanceof SelectorList<?> || !serializedAttributes.containsKey(attr)) {
+        // We didn't already serialize the attribute or it's a SelectorList. Latter may
+        // have been flattened while we want the full representation, so we start from scratch.
+        attrPb =
+            AttributeFormatter.getAttributeProto(
+                attr,
+                valueToHash,
+                /* explicitlySpecified= */ false, // We care about value, not how it was set.
+                /*encodeBooleanAndTriStateAsIntegerAndString=*/ false);
+      } else {
+        attrPb = serializedAttributes.get(attr);
+      }
+
+      try {
+        attrPb.writeTo(codedOut);
+      } catch (IOException e) {
+        throw new IllegalStateException("Unexpected IO failure writing to digest stream", e);
+      }
+    }
+
+    try {
+      // Rules can be considered changed when the containing package goes in/out of error.
+      codedOut.writeBoolNoTag(rule.getPackage().containsErrors());
+    } catch (IOException e) {
+      throw new IllegalStateException("Unexpected IO failure writing to digest stream", e);
+    }
+
+    try {
+      // Include a summary of any package-wide data that applies to this target (e.g. custom make
+      // variables aka `vardef`).
+      codedOut.writeStringNoTag((String) extraDataForAttrHash);
+    } catch (IOException e) {
+      throw new IllegalStateException("Unexpected IO failure writing to digest stream", e);
+    }
+
+    try {
+      // Flush coded out to make sure all bytes make it to the underlying digest stream.
+      codedOut.flush();
+    } catch (IOException e) {
+      throw new IllegalStateException("Unexpected flush failure", e);
+    }
+
+    return BaseEncoding.base64().encode(hashingOutputStream.hash().asBytes());
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/query2/query/output/SyntheticAttributeHashCalculatorTest.java b/src/test/java/com/google/devtools/build/lib/query2/query/output/SyntheticAttributeHashCalculatorTest.java
new file mode 100644
index 0000000..94a49a9
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/query2/query/output/SyntheticAttributeHashCalculatorTest.java
@@ -0,0 +1,161 @@
+// Copyright 2020 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.devtools.build.lib.query2.query.output;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.devtools.build.lib.cmdline.PackageIdentifier;
+import com.google.devtools.build.lib.packages.Attribute;
+import com.google.devtools.build.lib.packages.NoSuchPackageException;
+import com.google.devtools.build.lib.packages.Package;
+import com.google.devtools.build.lib.packages.Rule;
+import com.google.devtools.build.lib.packages.util.PackageLoadingTestCase;
+import com.google.devtools.build.lib.query2.proto.proto2api.Build;
+import com.google.devtools.build.lib.query2.proto.proto2api.Build.Attribute.Discriminator;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.RootedPath;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link SyntheticAttributeHashCalculator}. */
+@RunWith(JUnit4.class)
+public class SyntheticAttributeHashCalculatorTest extends PackageLoadingTestCase {
+
+  @Test
+  public void testComputeAttributeChangeChangesHash() throws Exception {
+    Path buildFile = scratch.file("pkg/BUILD");
+
+    scratch.overwriteFile("pkg/BUILD", "genrule(name='x', cmd='touch $@', outs=['y'])");
+    Rule ruleBefore = getRule(buildFile, "x");
+
+    scratch.overwriteFile("pkg/BUILD", "genrule(name='x', cmd='touch $@', outs=['z'])");
+    Rule ruleAfter = getRule(buildFile, "x");
+
+    String hashBefore =
+        SyntheticAttributeHashCalculator.compute(
+            ruleBefore, /*serializedAttributes=*/ ImmutableMap.of(), /*extraDataForAttrHash=*/ "");
+    String hashAfter =
+        SyntheticAttributeHashCalculator.compute(
+            ruleAfter, /*serializedAttributes=*/ ImmutableMap.of(), /*extraDataForAttrHash=*/ "");
+
+    assertThat(hashBefore).isNotEqualTo(hashAfter);
+  }
+
+  @Test
+  public void testComputeLocationDoesntChangeHash() throws Exception {
+    Path buildFile = scratch.file("pkg/BUILD");
+
+    scratch.overwriteFile("pkg/BUILD", "genrule(name='x', cmd='touch $@', outs=['y'])");
+    Rule ruleBefore = getRule(buildFile, "x");
+
+    scratch.overwriteFile(
+        "pkg/BUILD",
+        "genrule(name='rule_that_moves_x', cmd='touch $@', outs=['whatever'])",
+        "genrule(name='x', cmd='touch $@', outs=['y'])");
+    Rule ruleAfter = getRule(buildFile, "x");
+
+    String hashBefore =
+        SyntheticAttributeHashCalculator.compute(
+            ruleBefore, /*serializedAttributes=*/ ImmutableMap.of(), /*extraDataForAttrHash=*/ "");
+    String hashAfter =
+        SyntheticAttributeHashCalculator.compute(
+            ruleAfter, /*serializedAttributes=*/ ImmutableMap.of(), /*extraDataForAttrHash=*/ "");
+
+    assertThat(hashBefore).isEqualTo(hashAfter);
+  }
+
+  @Test
+  public void testComputeSerializedAttributesUsedOverAvailable() throws Exception {
+    Rule rule =
+        getRule(scratch.file("pkg/BUILD", "genrule(name='x', cmd='touch $@', outs=['y'])"), "x");
+
+    String hashBefore =
+        SyntheticAttributeHashCalculator.compute(
+            rule, /*serializedAttributes=*/ ImmutableMap.of(), /*extraDataForAttrHash=*/ "");
+
+    ImmutableMap<Attribute, Build.Attribute> serializedAttributes =
+        ImmutableMap.of(
+            rule.getRuleClassObject().getAttributeByName("cmd"),
+            Build.Attribute.newBuilder()
+                .setName("dummy")
+                .setType(Discriminator.STRING)
+                .setStringValue("hi")
+                .build());
+
+    String hashAfter =
+        SyntheticAttributeHashCalculator.compute(
+            rule, serializedAttributes, /*extraDataForAttrHash*/ "");
+
+    assertThat(hashBefore).isNotEqualTo(hashAfter);
+  }
+
+  @Test
+  public void testComputeExtraDataChangesHash() throws Exception {
+    Rule rule =
+        getRule(scratch.file("pkg/BUILD", "genrule(name='x', cmd='touch $@', outs=['y'])"), "x");
+
+    String hashBefore =
+        SyntheticAttributeHashCalculator.compute(
+            rule, /*serializedAttributes=*/ ImmutableMap.of(), /*extraDataForAttrHash=*/ "");
+
+    String hashAfter =
+        SyntheticAttributeHashCalculator.compute(
+            rule,
+            /*serializedAttributes=*/ ImmutableMap.of(), /*extraDataForAttrHash*/
+            "blahblaah");
+
+    assertThat(hashBefore).isNotEqualTo(hashAfter);
+  }
+
+  @Test
+  public void testComputePackageErrorStatusChangesHash() throws Exception {
+    Path buildFile = scratch.file("pkg/BUILD");
+
+    scratch.overwriteFile("pkg/BUILD", "genrule(name='x', cmd='touch $@', outs=['y'])");
+    Rule ruleBefore = getRule(buildFile, "x");
+
+    // Remove fail-fast handler, we're intentionally creating a package with errors.
+    reporter.removeHandler(failFastHandler);
+    scratch.overwriteFile(
+        "pkg/BUILD",
+        "genrule(name='x', cmd='touch $@', outs=['z'])",
+        "genrule(name='missing_attributes')");
+    Rule ruleAfter = getRule(buildFile, "x");
+    assertThat(ruleAfter.containsErrors()).isTrue();
+
+    String hashBefore =
+        SyntheticAttributeHashCalculator.compute(
+            ruleBefore, /*serializedAttributes=*/ ImmutableMap.of(), /*extraDataForAttrHash=*/ "");
+    String hashAfter =
+        SyntheticAttributeHashCalculator.compute(
+            ruleAfter, /*serializedAttributes=*/ ImmutableMap.of(), /*extraDataForAttrHash=*/ "");
+
+    assertThat(hashBefore).isNotEqualTo(hashAfter);
+  }
+
+  private Rule getRule(Path buildFile, String rule)
+      throws NoSuchPackageException, InterruptedException {
+    Package pkg =
+        packageFactory.createPackageForTesting(
+            PackageIdentifier.createInMainRepo(buildFile.getParentDirectory().getBaseName()),
+            RootedPath.toRootedPath(root, buildFile),
+            getPackageManager(),
+            reporter);
+
+    return pkg.getRule(rule);
+  }
+}