Add support for new 'symlink_outputs' reserved variable to AOSP Ninja statements.

This variable contains a space-separated list of paths representing
declared symlink outputs that an edge creates.

If `-o usessymlinkoutputs=yes`, Ninja will check that symlink outputs
are in this list, and file outputs are not in this list. Otherwise, it
prints a warning. Defaults to `usesymlinkoutputs=no`, which does not
affect existing Ninja files (unless symlink_outputs is already being
used, which isn't the case in AOSP).

`-w undeclaredsymlinkoutputs=err` turns that warning into error.

This is not necessary today because AOSP Ninja (not upstream Ninja)runs
`lstat` on all outputs, which would return the correct metadata
regardless if the output is a symlink or a file. However, tooling
integration with Ninja files require symlink outputs to be marked as
such, and aggregating them in the symlink_outputs variable is probably
the least invasive approach.

Test: (in build-tools) OUT_DIR=out build/soong/soong_ui.bash --make-mode --skip-make ninja ninja_test && out/soong/host/linux-x86/nativetest64/ninja_test/ninja_test
Test: (in AOSP) m NINJA_ARGS="-o usessymlinkoutputs=yes"
Test: (in AOSP) m NINJA_ARGS="-o usessymlinkoutputs=yes -w undeclaredsymlinkoutputs=err"

Bug: 160568334

Change-Id: Iae69ccb6014cace9ab6e61e0b6aca00f6d6ac8c6
diff --git a/src/build.cc b/src/build.cc
index 1ee7603..5bcc5de 100644
--- a/src/build.cc
+++ b/src/build.cc
@@ -16,9 +16,12 @@
 
 #include <assert.h>
 #include <errno.h>
+#include <functional>
+#include <set>
+#include <sstream>
 #include <stdio.h>
 #include <stdlib.h>
-#include <functional>
+#include <vector>
 
 #ifdef _WIN32
 #include <fcntl.h>
@@ -559,7 +562,7 @@
         // but is interrupted before it touches its output file.)
         string err;
         bool is_dir = false;
-        TimeStamp new_mtime = disk_interface_->LStat((*o)->path(), &is_dir, &err);
+        TimeStamp new_mtime = disk_interface_->LStat((*o)->path(), &is_dir, nullptr, &err);
         if (new_mtime == -1)  // Log and ignore LStat() errors.
           status_->Error("%s", err.c_str());
         if (!is_dir && (!depfile.empty() || (*o)->mtime() != new_mtime))
@@ -815,13 +818,69 @@
       }
     }
 
+    set<string> declared_symlinks;
+    if (config_.uses_symlink_outputs) {
+      string symlink_outputs = edge->GetSymlinkOutputs();
+      if (symlink_outputs.length() > 0) {
+        stringstream ss(symlink_outputs);
+        string path;
+        /// Naively split symlink_outputs path by the empty ' ' space character.
+        /// because the '$ ' escape doesn't exist at this stage. In experimentation
+        /// and practice across a number of AOSP configurations, this is OK.
+        ///
+        /// We could modify the GetBindingImpl/GetSymlinkOutputs API to support lists,
+        /// but it'd be an invasive change that'll require a little bit more designing.
+        /// For example, how do we expand "${out}.d" if ${out} is a list?
+        ///
+        /// That said, keep in mind that this is a simple string split that could
+        /// fail with paths containing spaces.
+        while (getline(ss, path, ' ')) {
+          uint64_t slash_bits;
+          if (!CanonicalizePath(&path, &slash_bits, err)) {
+            return false;
+          }
+          declared_symlinks.insert(move(path));
+        }
+      }
+    }
+
     for (vector<Node*>::iterator o = edge->outputs_.begin();
          o != edge->outputs_.end(); ++o) {
       bool is_dir = false;
+      bool is_symlink = false;
       TimeStamp old_mtime = (*o)->mtime();
-      if (!(*o)->LStat(disk_interface_, &is_dir, err))
+      if (!(*o)->LStat(disk_interface_, &is_dir, &is_symlink, err))
         return false;
+
       TimeStamp new_mtime = (*o)->mtime();
+
+      if (config_.uses_symlink_outputs) {
+        /// Warn or error if created symlinks aren't declared in symlink_outputs,
+        /// or if created files are declared in symlink_outputs.
+        string path = (*o)->path();
+        if (is_symlink) {
+          if (declared_symlinks.find(path) == declared_symlinks.end()) {
+            // Not in declared_symlinks
+            if (!result->output.empty())
+              result->output.append("\n");
+            result->output.append("ninja: " + path + " is a symlink, but it was not declared in symlink_outputs");
+            if (config_.undeclared_symlink_outputs_should_err) {
+              result->status = ExitFailure;
+            }
+          } else {
+            declared_symlinks.erase(path);
+          }
+        } else if (!is_symlink && declared_symlinks.find(path) != declared_symlinks.end()) {
+          if (!result->output.empty())
+            result->output.append("\n");
+          result->output.append("ninja: " + path + " is not a symlink, but it was declared in symlink_outputs");
+          declared_symlinks.erase(path);
+          if (config_.undeclared_symlink_outputs_should_err) {
+            result->status = ExitFailure;
+          }
+        }
+      }
+
       if (config_.uses_phony_outputs) {
         if (new_mtime == 0) {
           if (!result->output.empty())
@@ -860,6 +919,21 @@
       }
     }
 
+    /// Ensure that declared_symlinks is empty after verifying that symlink outputs
+    /// were declared in the edge. A non-empty declared_symlinks set indicates that
+    /// not all declared symlinks were created by the edge itself (over-specification).
+    if (config_.uses_symlink_outputs && declared_symlinks.size() > 0) {
+      string missing_outputs;
+      for (string symlink : declared_symlinks) {
+        missing_outputs = missing_outputs + " " + symlink;
+      }
+      result->output.append(
+        "ninja: not all symlink_outputs were created for this edge:" + missing_outputs);
+      if (config_.undeclared_symlink_outputs_should_err) {
+        result->status = ExitFailure;
+      }
+    }
+
     status_->BuildEdgeFinished(edge, end_time_millis, result);
 
     if (result->success() && !nodes_cleaned.empty()) {
@@ -918,7 +992,7 @@
 
   if (!deps_type.empty() && !config_.dry_run && !phony_output) {
     Node* out = edge->outputs_[0];
-    TimeStamp deps_mtime = disk_interface_->LStat(out->path(), nullptr, err);
+    TimeStamp deps_mtime = disk_interface_->LStat(out->path(), nullptr, nullptr, err);
     if (deps_mtime == -1)
       return false;
     if (!scan_.deps_log()->RecordDeps(out, deps_mtime, deps_nodes)) {
diff --git a/src/build.h b/src/build.h
index 61d3f78..45938ad 100644
--- a/src/build.h
+++ b/src/build.h
@@ -166,6 +166,8 @@
                   failures_allowed(1), max_load_average(-0.0f),
                   frontend(NULL), frontend_file(NULL),
                   missing_depfile_should_err(false),
+                  uses_symlink_outputs(false),
+                  undeclared_symlink_outputs_should_err(false),
                   uses_phony_outputs(false),
                   output_directory_should_err(false),
                   missing_output_file_should_err(false),
@@ -195,6 +197,13 @@
   /// Whether a missing depfile should warn or print an error.
   bool missing_depfile_should_err;
 
+  /// Whether Ninja should check that symlink outputs are declared in the
+  /// symlink_outputs variable
+  bool uses_symlink_outputs;
+
+  /// Whether undeclared symlink outputs should print a warning or error out
+  bool undeclared_symlink_outputs_should_err;
+
   /// Whether the generator uses 'phony_output's
   /// Controls the warnings below
   bool uses_phony_outputs;
diff --git a/src/build_test.cc b/src/build_test.cc
index 6b8e178..e8faf9d 100644
--- a/src/build_test.cc
+++ b/src/build_test.cc
@@ -651,6 +651,14 @@
     if (fs_->ReadFile(edge->inputs_[0]->path(), &content, &err) ==
         DiskInterface::Okay)
       fs_->WriteFile(edge->outputs_[0]->path(), content);
+  } else if (edge->rule().name() == "symlink") {
+    assert(edge->inputs_.size() == 1);
+    assert(edge->outputs_.size() == 1);
+    fs_->CreateSymlink(edge->outputs_[0]->path(), edge->inputs_[0]->path());
+  } else if (edge->rule().name() == "dangling_symlink") {
+    assert(edge->inputs_.empty());
+    assert(edge->outputs_.size() == 1);
+    fs_->CreateSymlink(edge->outputs_[0]->path(), "nil");
   } else {
     printf("unknown command\n");
     return false;
@@ -845,6 +853,232 @@
   EXPECT_EQ("touch out out.imp", command_runner_.commands_ran_[0]);
 }
 
+TEST_F(BuildTest, SymlinkOutputsIsValidVariable) {
+  string err;
+  ASSERT_NO_FATAL_FAILURE(AssertParse(&state_,
+"rule dangling_symlink\n"
+"  command = ln -sf nil $out\n"
+"  symlink_outputs = $out\n"
+"build l1: dangling_symlink\n"
+"rule symlink\n"
+"  command = ln -sf $in $out\n"
+"  symlink_outputs = $out\n"
+"build l2: symlink file\n"
+))
+
+  fs_.Create("file", "content");
+  /// Disabled, but symlink_outputs is still a valid variable.
+  config_.uses_symlink_outputs = false;
+
+  EXPECT_TRUE(builder_.AddTarget("l1", &err));
+  EXPECT_TRUE(builder_.AddTarget("l2", &err));
+  EXPECT_TRUE(builder_.Build(&err));
+  EXPECT_EQ("", err);
+  EXPECT_EQ(2u, command_runner_.commands_ran_.size());
+}
+
+TEST_F(BuildTest, SymlinkOutputsOKWithDeclaration) {
+  string err;
+  ASSERT_NO_FATAL_FAILURE(AssertParse(&state_,
+"rule dangling_symlink\n"
+"  command = ln -sf nil $out\n"
+"  symlink_outputs = $out\n"
+"build l1: dangling_symlink\n"
+"rule symlink\n"
+"  command = ln -sf $in $out\n"
+"  symlink_outputs = $out\n"
+"build l2: symlink file\n"
+))
+
+  config_.uses_symlink_outputs = true;
+  config_.undeclared_symlink_outputs_should_err = true;
+
+  fs_.Create("file", "content");
+
+  EXPECT_TRUE(builder_.AddTarget("l1", &err));
+  EXPECT_TRUE(builder_.AddTarget("l2", &err));
+  EXPECT_TRUE(builder_.Build(&err));
+  EXPECT_EQ("", err);
+  EXPECT_EQ(2u, command_runner_.commands_ran_.size());
+}
+
+TEST_F(BuildTest, SymlinkOutputsOKWithUncanonicalizedDeclaration) {
+  string err;
+  ASSERT_NO_FATAL_FAILURE(AssertParse(&state_,
+"rule dangling_symlink\n"
+"  command = ln -sf nil .//$out\n"
+"  symlink_outputs = ././$out\n"
+"build l1: dangling_symlink\n"
+"rule symlink\n"
+"  command = ln -sf $in ././$out\n"
+"  symlink_outputs = .//$out\n"
+"build l2: symlink file\n"
+))
+
+  config_.uses_symlink_outputs = true;
+  config_.undeclared_symlink_outputs_should_err = true;
+
+  fs_.Create("file", "content");
+
+  EXPECT_TRUE(builder_.AddTarget("l1", &err));
+  EXPECT_TRUE(builder_.AddTarget("l2", &err));
+  EXPECT_TRUE(builder_.Build(&err));
+  EXPECT_EQ("", err);
+  EXPECT_EQ(2u, command_runner_.commands_ran_.size());
+}
+
+TEST_F(BuildTest, FileOutputsWarnWithSymlinkOutputsDeclaration) {
+  ASSERT_NO_FATAL_FAILURE(AssertParse(&state_,
+"rule touch\n"
+"  command = touch $out\n"
+"  symlink_outputs = $out\n"
+"build f1: touch\n"));
+
+  config_.uses_symlink_outputs = true;
+  config_.undeclared_symlink_outputs_should_err = false;
+
+  string err;
+  EXPECT_TRUE(builder_.AddTarget("f1", &err));
+  EXPECT_EQ("", err);
+
+  EXPECT_TRUE(builder_.Build(&err));
+  EXPECT_EQ("ninja: f1 is not a symlink, but it was declared in symlink_outputs", status_.last_output_);
+}
+
+TEST_F(BuildTest, FileOutputsErrorWithSymlinkOutputsDeclaration) {
+  ASSERT_NO_FATAL_FAILURE(AssertParse(&state_,
+"rule touch\n"
+"  command = touch $out\n"
+"  symlink_outputs = $out\n"
+"build f1: touch\n"));
+
+  config_.uses_symlink_outputs = true;
+  config_.undeclared_symlink_outputs_should_err = true;
+
+  string err;
+  EXPECT_TRUE(builder_.AddTarget("f1", &err));
+  EXPECT_EQ("", err);
+
+  EXPECT_FALSE(builder_.Build(&err));
+  EXPECT_EQ("subcommand failed", err);
+  EXPECT_EQ("ninja: f1 is not a symlink, but it was declared in symlink_outputs", status_.last_output_);
+}
+
+TEST_F(BuildTest, DanglingSymlinkOutputsWarnWithoutDeclaration) {
+  ASSERT_NO_FATAL_FAILURE(AssertParse(&state_,
+"rule dangling_symlink\n"
+"  command = ln -sf nil $out\n"
+"build l1: dangling_symlink\n"
+))
+
+  config_.uses_symlink_outputs = true;
+  config_.undeclared_symlink_outputs_should_err = false;
+
+  string err;
+  EXPECT_TRUE(builder_.AddTarget("l1", &err));
+  EXPECT_EQ("", err);
+
+  EXPECT_TRUE(builder_.Build(&err));
+  EXPECT_EQ("ninja: l1 is a symlink, but it was not declared in symlink_outputs", status_.last_output_);
+}
+
+TEST_F(BuildTest, RegularSymlinkOutputsWarnWithoutDeclaration) {
+  ASSERT_NO_FATAL_FAILURE(AssertParse(&state_,
+"rule symlink\n"
+"  command = ln -sf $in $out\n"
+"build l1: symlink file\n"
+))
+  fs_.Create("file", "content");
+
+  config_.uses_symlink_outputs = true;
+  config_.undeclared_symlink_outputs_should_err = false;
+
+  string err;
+  EXPECT_TRUE(builder_.AddTarget("l1", &err));
+  EXPECT_EQ("", err);
+
+  EXPECT_TRUE(builder_.Build(&err));
+  EXPECT_EQ("ninja: l1 is a symlink, but it was not declared in symlink_outputs", status_.last_output_);
+}
+
+TEST_F(BuildTest, DanglingSymlinkOutputsErrorWithoutDeclaration) {
+  ASSERT_NO_FATAL_FAILURE(AssertParse(&state_,
+"rule dangling_symlink\n"
+"  command = ln -sf nil $out\n"
+"build l1: dangling_symlink\n"
+))
+
+  config_.uses_symlink_outputs = true;
+  config_.undeclared_symlink_outputs_should_err = true;
+
+  string err;
+  EXPECT_TRUE(builder_.AddTarget("l1", &err));
+  EXPECT_EQ("", err);
+
+  EXPECT_FALSE(builder_.Build(&err));
+  EXPECT_EQ("subcommand failed", err);
+  EXPECT_EQ("ninja: l1 is a symlink, but it was not declared in symlink_outputs", status_.last_output_);
+}
+
+TEST_F(BuildTest, RegularSymlinkOutputsErrorWithoutDeclaration) {
+  ASSERT_NO_FATAL_FAILURE(AssertParse(&state_,
+"rule symlink\n"
+"  command = ln -sf $in $out\n"
+"build l1: symlink file\n"
+))
+  fs_.Create("file", "content");
+
+  config_.uses_symlink_outputs = true;
+  config_.undeclared_symlink_outputs_should_err = true;
+
+  string err;
+  EXPECT_TRUE(builder_.AddTarget("l1", &err));
+  EXPECT_EQ("", err);
+
+  EXPECT_FALSE(builder_.Build(&err));
+  EXPECT_EQ("subcommand failed", err);
+  EXPECT_EQ("ninja: l1 is a symlink, but it was not declared in symlink_outputs", status_.last_output_);
+}
+
+TEST_F(BuildTest, ExtraSymlinkOutputsPrintsWarning) {
+  ASSERT_NO_FATAL_FAILURE(AssertParse(&state_,
+"rule dangling_symlink\n"
+"  command = ln -sf nil $out\n"
+"build l1: dangling_symlink\n"
+"  symlink_outputs = l1 l2\n"
+))
+
+  config_.uses_symlink_outputs = true;
+  config_.undeclared_symlink_outputs_should_err = false;
+
+  string err;
+  EXPECT_TRUE(builder_.AddTarget("l1", &err));
+  EXPECT_EQ("", err);
+
+  EXPECT_TRUE(builder_.Build(&err));
+  EXPECT_EQ("ninja: not all symlink_outputs were created for this edge: l2", status_.last_output_);
+}
+
+TEST_F(BuildTest, ExtraSymlinkOutputsRaisesError) {
+  ASSERT_NO_FATAL_FAILURE(AssertParse(&state_,
+"rule dangling_symlink\n"
+"  command = ln -sf nil $out\n"
+"build l1: dangling_symlink\n"
+"  symlink_outputs = l1 l2\n"
+))
+
+  config_.uses_symlink_outputs = true;
+  config_.undeclared_symlink_outputs_should_err = true;
+
+  string err;
+  EXPECT_TRUE(builder_.AddTarget("l1", &err));
+  EXPECT_EQ("", err);
+
+  EXPECT_FALSE(builder_.Build(&err));
+  EXPECT_EQ("subcommand failed", err);
+  EXPECT_EQ("ninja: not all symlink_outputs were created for this edge: l2", status_.last_output_);
+}
+
 // Test case from
 //   https://github.com/ninja-build/ninja/issues/148
 TEST_F(BuildTest, MultiOutIn) {
diff --git a/src/deps_log.cc b/src/deps_log.cc
index f2a3888..de7a3cd 100644
--- a/src/deps_log.cc
+++ b/src/deps_log.cc
@@ -709,7 +709,7 @@
       // If the current manifest does not define this edge, skip if it's missing
       // from the disk.
       string err;
-      TimeStamp mtime = disk.LStat(node->path(), nullptr, &err);
+      TimeStamp mtime = disk.LStat(node->path(), nullptr, nullptr, &err);
       if (mtime == -1)
         Error("%s", err.c_str()); // log and ignore LStat() errors
       if (mtime == 0)
diff --git a/src/disk_interface.cc b/src/disk_interface.cc
index 0044c5b..4a5ea34 100644
--- a/src/disk_interface.cc
+++ b/src/disk_interface.cc
@@ -229,7 +229,8 @@
 #endif
 }
 
-TimeStamp RealDiskInterface::LStat(const string& path, bool* is_dir, string* err) const {
+TimeStamp RealDiskInterface::LStat(
+  const string& path, bool* is_dir, bool* is_symlink, string* err) const {
   METRIC_RECORD("node lstat");
 #ifdef _WIN32
 #error unimplemented
@@ -244,6 +245,10 @@
   if (is_dir != nullptr) {
     *is_dir = S_ISDIR(st.st_mode);
   }
+
+  if (is_symlink != nullptr) {
+    *is_symlink = S_ISLNK(st.st_mode);
+  }
   return StatTimestamp(st);
 #endif
 }
diff --git a/src/disk_interface.h b/src/disk_interface.h
index f8b1b0d..66083c6 100644
--- a/src/disk_interface.h
+++ b/src/disk_interface.h
@@ -107,11 +107,11 @@
   /// other errors. Thread-safe iff IsStatThreadSafe returns true.
   virtual TimeStamp Stat(const string& path, string* err) const = 0;
 
-  /// lstat() a path, returning the mtime, or 0 if missing and 01 on
+  /// lstat() a path, returning the mtime, or 0 if missing and -1 on
   /// other errors. Does not traverse symlinks, and returns whether the
-  /// path represents a directory. Thread-safe iff IsStatThreadSafe
-  /// returns true.
-  virtual TimeStamp LStat(const string& path, bool* is_dir, string* err) const = 0;
+  /// path represents a directory or a symlink. Thread-safe iff
+  /// IsStatThreadSafe returns true.
+  virtual TimeStamp LStat(const string& path, bool* is_dir, bool* is_symlink, string* err) const = 0;
 
   /// True if Stat() can be called from multiple threads concurrently.
   virtual bool IsStatThreadSafe() const = 0;
@@ -144,7 +144,7 @@
                       {}
   virtual ~RealDiskInterface() {}
   virtual TimeStamp Stat(const string& path, string* err) const;
-  virtual TimeStamp LStat(const string& path, bool* is_dir, string* err) const;
+  virtual TimeStamp LStat(const string& path, bool* is_dir, bool* is_symlink, string* err) const;
   virtual bool IsStatThreadSafe() const;
   virtual bool MakeDir(const string& path);
   virtual bool WriteFile(const string& path, const string& contents);
diff --git a/src/disk_interface_test.cc b/src/disk_interface_test.cc
index 090adc4..fe8ab73 100644
--- a/src/disk_interface_test.cc
+++ b/src/disk_interface_test.cc
@@ -217,7 +217,7 @@
 
   // DiskInterface implementation.
   virtual TimeStamp Stat(const string& path, string* err) const;
-  virtual TimeStamp LStat(const string& path, bool* is_dir, string* err) const;
+  virtual TimeStamp LStat(const string& path, bool* is_dir, bool* is_symlink, string* err) const;
   virtual bool IsStatThreadSafe() const;
   virtual bool WriteFile(const string& path, const string& contents) {
     assert(false);
@@ -248,16 +248,18 @@
 };
 
 TimeStamp StatTest::Stat(const string& path, string* err) const {
-  return LStat(path, nullptr, err);
+  return LStat(path, nullptr, nullptr, err);
 }
 
-TimeStamp StatTest::LStat(const string& path, bool* is_dir, string* err) const {
+TimeStamp StatTest::LStat(const string& path, bool* is_dir, bool* is_symlink, string* err) const {
   stats_.push_back(path);
   map<string, TimeStamp>::const_iterator i = mtimes_.find(path);
   if (i == mtimes_.end())
     return 0;  // File not found.
   if (is_dir != nullptr)
     *is_dir = false;
+  if (is_symlink != nullptr)
+    *is_symlink = false;
   return i->second;
 }
 
diff --git a/src/eval_env.cc b/src/eval_env.cc
index b52436b..0a30e66 100644
--- a/src/eval_env.cc
+++ b/src/eval_env.cc
@@ -41,6 +41,7 @@
       var == "rspfile" ||
       var == "rspfile_content" ||
       var == "phony_output" ||
+      var == "symlink_outputs" ||
       var == "msvc_deps_prefix";
 }
 
diff --git a/src/graph.cc b/src/graph.cc
index 0dfcda2..747784e 100644
--- a/src/graph.cc
+++ b/src/graph.cc
@@ -33,7 +33,7 @@
     if (in_edge()->IsPhonyOutput()) {
       return true;
     }
-    return (precomputed_mtime_ = disk_interface->LStat(path_.str(), nullptr, err)) != -1;
+    return (precomputed_mtime_ = disk_interface->LStat(path_.str(), nullptr, nullptr, err)) != -1;
   } else {
     return (precomputed_mtime_ = disk_interface->Stat(path_.str(), err)) != -1;
   }
@@ -42,16 +42,17 @@
 bool Node::Stat(DiskInterface* disk_interface, string* err) {
   if (in_edge() != nullptr) {
     assert(!in_edge()->IsPhonyOutput());
-    return (mtime_ = disk_interface->LStat(path_.str(), nullptr, err)) != -1;
+    return (mtime_ = disk_interface->LStat(path_.str(), nullptr, nullptr, err)) != -1;
   } else {
     return (mtime_ = disk_interface->Stat(path_.str(), err)) != -1;
   }
 }
 
-bool Node::LStat(DiskInterface* disk_interface, bool* is_dir, string* err) {
+bool Node::LStat(
+  DiskInterface* disk_interface, bool* is_dir, bool* is_symlink, string* err) {
   assert(in_edge() != nullptr);
   assert(!in_edge()->IsPhonyOutput());
-  return (mtime_ = disk_interface->LStat(path_.str(), is_dir, err)) != -1;
+  return (mtime_ = disk_interface->LStat(path_.str(), is_dir, is_symlink, err)) != -1;
 }
 
 bool DependencyScan::RecomputeNodesDirty(const std::vector<Node*>& initial_nodes,
@@ -603,6 +604,7 @@
 static const HashedStrView kDyndep          { "dyndep" };
 static const HashedStrView kRspfile         { "rspfile" };
 static const HashedStrView kRspFileContent  { "rspfile_content" };
+static const HashedStrView kSymlinkOutputs  { "symlink_outputs" };
 
 bool Edge::EvaluateCommand(std::string* out_append, bool incl_rsp_file,
                            std::string* err) {
@@ -699,6 +701,10 @@
   return GetBindingImpl(key, EdgeEval::kFinalScope, EdgeEval::kShellEscape);
 }
 
+std::string Edge::GetSymlinkOutputs() {
+  return GetBindingImpl(kSymlinkOutputs, EdgeEval::kFinalScope, EdgeEval::kDoNotEscape);
+}
+
 std::string Edge::GetUnescapedDepfile() {
   return GetBindingImpl(kDepfile, EdgeEval::kFinalScope, EdgeEval::kDoNotEscape);
 }
diff --git a/src/graph.h b/src/graph.h
index 776c1ac..7028912 100644
--- a/src/graph.h
+++ b/src/graph.h
@@ -117,7 +117,7 @@
   bool Stat(DiskInterface* disk_interface, string* err);
 
   /// Only use when lstat() is desired (output files)
-  bool LStat(DiskInterface* disk_interface, bool* is_dir, string* err);
+  bool LStat(DiskInterface* disk_interface, bool* is_dir, bool* is_symlink, string* err);
 
   /// Return false on error.
   bool StatIfNecessary(DiskInterface* disk_interface, string* err) {
@@ -373,6 +373,8 @@
   /// Like GetBinding("rspfile"), but without shell escaping.
   string GetUnescapedRspfile();
 
+  string GetSymlinkOutputs();
+
   void Dump(const char* prefix="") const;
 
   /// Temporary fields used only during manifest parsing.
diff --git a/src/ninja.cc b/src/ninja.cc
index 65cc8aa..375e50d 100644
--- a/src/ninja.cc
+++ b/src/ninja.cc
@@ -181,7 +181,7 @@
     // Do keep entries around for files which still exist on disk, for
     // generators that want to use this information.
     string err;
-    TimeStamp mtime = disk_interface_.LStat(s.AsString(), nullptr, &err);
+    TimeStamp mtime = disk_interface_.LStat(s.AsString(), nullptr, nullptr, &err);
     if (mtime == -1)
       Error("%s", err.c_str());  // Log and ignore Stat() errors.
     return mtime == 0;
@@ -1132,7 +1132,11 @@
 " requires -o usesphonyoutputs=yes\n"
 "  outputdir={err,warn}  how to treat outputs that are directories\n"
 "  missingoutfile={err,warn}  how to treat missing output files\n"
-"  oldoutput={err,warn}  how to treat output files older than their inputs\n");
+"  oldoutput={err,warn}  how to treat output files older than their inputs\n"
+"\n"
+" requires -o usessymlinkoutputs=yes\n"
+"  undeclaredsymlinkoutputs={err,warn}  build statements creating symlink outputs must "
+"declare them in symlink_outputs\n");
     return false;
   } else if (name == "dupbuild=err") {
     options->dupe_edges_should_err = true;
@@ -1176,6 +1180,12 @@
   } else if (name == "oldoutput=warn") {
     config->old_output_should_err = false;
     return true;
+  } else if (name == "undeclaredsymlinkoutputs=err") {
+    config->undeclared_symlink_outputs_should_err = true;
+    return true;
+  } else if (name == "undeclaredsymlinkoutputs=warn") {
+    config->undeclared_symlink_outputs_should_err = false;
+    return true;
   } else {
     const char* suggestion =
         SpellcheckString(name.c_str(), "dupbuild=err", "dupbuild=warn",
@@ -1183,7 +1193,8 @@
                          "missingdepfile=err", "missingdepfile=warn",
                          "outputdir=err", "outputdir=warn",
                          "missingoutfile=err", "missingoutfile=warn",
-                         "oldoutput=err", "oldoutput=warn", NULL);
+                         "oldoutput=err", "oldoutput=warn",
+                         "undeclaredsymlinkoutputs=err", "undeclaredsymlinkoutputs=warn", NULL);
     if (suggestion) {
       Error("unknown warning flag '%s', did you mean '%s'?",
             name.c_str(), suggestion);
@@ -1204,6 +1215,9 @@
 "                                outputdir\n"
 "                                missingoutfile\n"
 "                                oldoutput\n"
+"  usessymlinkoutputs={yes,no}  whether the generate uses 'symlink_outputs' so \n"
+"                             that these warnings work:\n"
+"                                undeclaredsymlinkoutputs\n"
 "  preremoveoutputs={yes,no}  whether to remove outputs before running rule\n");
     return false;
   } else if (name == "usesphonyoutputs=yes") {
@@ -1212,6 +1226,12 @@
   } else if (name == "usesphonyoutputs=no") {
     config->uses_phony_outputs = false;
     return true;
+  } else if (name == "usessymlinkoutputs=yes") {
+    config->uses_symlink_outputs = true;
+    return true;
+  } else if (name == "usessymlinkoutputs=no") {
+    config->uses_symlink_outputs = false;
+    return true;
   } else if (name == "preremoveoutputs=yes") {
     config->pre_remove_output_files = true;
     return true;
diff --git a/src/test.cc b/src/test.cc
index 86983cc..7cacae4 100644
--- a/src/test.cc
+++ b/src/test.cc
@@ -200,7 +200,7 @@
   return 0;
 }
 
-TimeStamp VirtualFileSystem::LStat(const string& path, bool* is_dir, string* err) const {
+TimeStamp VirtualFileSystem::LStat(const string& path, bool* is_dir, bool* is_symlink, string* err) const {
   DirMap::const_iterator d = dirs_.find(path);
   if (d != dirs_.end()) {
     if (is_dir != nullptr)
@@ -212,6 +212,8 @@
   if (i != files_.end()) {
     if (is_dir != nullptr)
       *is_dir = false;
+    if (is_symlink != nullptr)
+      *is_symlink = i->second.is_symlink;
     *err = i->second.stat_error;
     return i->second.mtime;
   }
diff --git a/src/test.h b/src/test.h
index 5d49b0a..4adc3c9 100644
--- a/src/test.h
+++ b/src/test.h
@@ -148,7 +148,7 @@
 
   // DiskInterface
   virtual TimeStamp Stat(const string& path, string* err) const;
-  virtual TimeStamp LStat(const string& path, bool* is_dir, string* err) const;
+  virtual TimeStamp LStat(const string& path, bool* is_dir, bool* is_symlink, string* err) const;
   virtual bool IsStatThreadSafe() const;
   virtual bool WriteFile(const string& path, const string& contents);
   virtual bool MakeDir(const string& path);