Author: Rashmi Mudduluru
Date: 2026-08-27T12:00:38-07:00
New Revision: a6bf0bd01b60df2544b051615c330a4b9b18f359

URL: 
https://github.com/llvm/llvm-project/commit/a6bf0bd01b60df2544b051615c330a4b9b18f359
DIFF: 
https://github.com/llvm/llvm-project/commit/a6bf0bd01b60df2544b051615c330a4b9b18f359.diff

LOG: [clang][ssaf][clang-reforge] Tool for merging per-TU source-edit files 
(#216183)

Add a new tool that merges per-TU
clang::tooling::TranslationUnitReplacements YAML files for one link unit
into a single merged YAML, deduplicating identical edits and dropping
conflicting overlapping edits rather than silently picking a winner. The
tool does not apply edits to source files; that remains the caller's
responsibility via clang-apply-replacements.

rdar://179151250

Added: 
    clang-tools-extra/clang-ssaf-src-edit-merge/CMakeLists.txt
    clang-tools-extra/clang-ssaf-src-edit-merge/SrcEditMerge.cpp
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/conflict-text-q-offset-30.tpl.yaml
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/conflict-text-y.tpl.yaml
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/conflict-text-z.tpl.yaml
    clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/empty.tpl.yaml
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/internal-conflict.tpl.yaml
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/non-overlapping-a.tpl.yaml
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/non-overlapping-b.tpl.yaml
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/range-overlap-cluster-of-three.tpl.yaml
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/range-overlap-distinct-offsets.tpl.yaml
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-non-zero-length-replacement.tpl.yaml
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-replacement-offset-30.tpl.yaml
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-replacement.tpl.yaml
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-zero-length-insert.tpl.yaml
    clang-tools-extra/test/clang-ssaf-src-edit-merge/bad-args.test
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/conflict-preserves-unrelated-zero-length-insert.test
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/conflict-stderr-without-sarif.test
    clang-tools-extra/test/clang-ssaf-src-edit-merge/empty-input.test
    clang-tools-extra/test/clang-ssaf-src-edit-merge/help.test
    clang-tools-extra/test/clang-ssaf-src-edit-merge/identical-overlap.test
    clang-tools-extra/test/clang-ssaf-src-edit-merge/lit.local.cfg
    clang-tools-extra/test/clang-ssaf-src-edit-merge/malformed-yaml.test
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/no-conflict-sarif-empty.test
    clang-tools-extra/test/clang-ssaf-src-edit-merge/non-overlapping.test
    clang-tools-extra/test/clang-ssaf-src-edit-merge/order-invariance.test
    clang-tools-extra/test/clang-ssaf-src-edit-merge/parent-dir-missing.test
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/pre-dedup-identical-non-zero-length.test
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/pre-dedup-zero-length-insert.test
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/range-overlap-cluster-of-three-sarif.test
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/range-overlap-conflict-sarif.test
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/sarif-out-parent-dir-missing.test
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/single-input-internal-conflict.test
    clang-tools-extra/test/clang-ssaf-src-edit-merge/single-input.test
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/three-input-all-conflict.test
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/three-input-conflict-sarif.test
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/two-conflicts-different-offsets-sarif.test
    
clang-tools-extra/test/clang-ssaf-src-edit-merge/two-input-conflict-sarif.test
    clang-tools-extra/test/clang-ssaf-src-edit-merge/two-input-conflict.test

Modified: 
    clang-tools-extra/CMakeLists.txt
    clang-tools-extra/test/CMakeLists.txt

Removed: 
    


################################################################################
diff  --git a/clang-tools-extra/CMakeLists.txt 
b/clang-tools-extra/CMakeLists.txt
index 87050db4e0e75..0dbdeaed9a9a2 100644
--- a/clang-tools-extra/CMakeLists.txt
+++ b/clang-tools-extra/CMakeLists.txt
@@ -27,6 +27,7 @@ add_subdirectory(clang-doc)
 add_subdirectory(clang-include-fixer)
 add_subdirectory(clang-move)
 add_subdirectory(clang-query)
+add_subdirectory(clang-ssaf-src-edit-merge)
 add_subdirectory(include-cleaner)
 add_subdirectory(pp-trace)
 add_subdirectory(tool-template)

diff  --git a/clang-tools-extra/clang-ssaf-src-edit-merge/CMakeLists.txt 
b/clang-tools-extra/clang-ssaf-src-edit-merge/CMakeLists.txt
new file mode 100644
index 0000000000000..a9045b9fd3f90
--- /dev/null
+++ b/clang-tools-extra/clang-ssaf-src-edit-merge/CMakeLists.txt
@@ -0,0 +1,12 @@
+set(LLVM_LINK_COMPONENTS
+  Support
+  )
+
+add_clang_tool(clang-ssaf-src-edit-merge
+  SrcEditMerge.cpp
+  )
+
+clang_target_link_libraries(clang-ssaf-src-edit-merge
+  PRIVATE
+  clangToolingCore
+  )

diff  --git a/clang-tools-extra/clang-ssaf-src-edit-merge/SrcEditMerge.cpp 
b/clang-tools-extra/clang-ssaf-src-edit-merge/SrcEditMerge.cpp
new file mode 100644
index 0000000000000..d66fc657f594b
--- /dev/null
+++ b/clang-tools-extra/clang-ssaf-src-edit-merge/SrcEditMerge.cpp
@@ -0,0 +1,539 @@
+//===- SrcEditMerge.cpp 
---------------------------------------------------===//
+//
+// Part of the LLVM Project, under the Apache License v2.0 with LLVM 
Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+//
+//===----------------------------------------------------------------------===//
+//
+// clang-ssaf-src-edit-merge: per-LU source-edit YAML merge tool.
+//
+// Reads N per-TU clang::tooling::TranslationUnitReplacements YAML files,
+// deduplicates and merges them into one flat, conflict-resolved list (see
+// "Conflict policy" below), and writes a single merged YAML spanning all N
+// TUs. The tool does NOT write source files — applying the merge result is
+// the caller's responsibility (typically clang-reforge invokes
+// `clang-apply-replacements` after this tool returns).
+//
+// Conflict policy: this tool implements a drop-all policy. For each file, a
+// maximal group of transitively-overlapping input Replacements (a cluster)
+// is computed directly from the input; if a cluster has more than one
+// member, every member is removed from the merged output — not just enough
+// of them to resolve the overlap. A one-line stderr summary is emitted per
+// dropped cluster, and the tool still exits 0. Any input file that does not
+// exist on disk is excluded entirely from the merged output.
+//
+//===----------------------------------------------------------------------===//
+
+#include "clang/Basic/Version.h"
+#include "clang/Tooling/ReplacementsYaml.h" // IWYU pragma: keep
+#include "llvm/ADT/ArrayRef.h"
+#include "llvm/ADT/STLExtras.h"
+#include "llvm/ADT/SmallString.h"
+#include "llvm/ADT/StringRef.h"
+#include "llvm/Support/CommandLine.h"
+#include "llvm/Support/Error.h"
+#include "llvm/Support/ErrorOr.h"
+#include "llvm/Support/FileSystem.h"
+#include "llvm/Support/FormatVariadic.h"
+#include "llvm/Support/InitLLVM.h"
+#include "llvm/Support/JSON.h"
+#include "llvm/Support/MemoryBuffer.h"
+#include "llvm/Support/Path.h"
+#include "llvm/Support/YAMLTraits.h"
+#include "llvm/Support/raw_ostream.h"
+
+#include <algorithm>
+#include <map>
+#include <set>
+#include <string>
+#include <utility>
+#include <vector>
+
+namespace {
+
+namespace cl = llvm::cl;
+
+//===----------------------------------------------------------------------===//
+// Error Messages
+//===----------------------------------------------------------------------===//
+
+constexpr const char *ToolName = "clang-ssaf-src-edit-merge";
+
+constexpr const char *CannotReadInput = "cannot read {0}: {1}";
+
+constexpr const char *InvalidReplacementsYaml =
+    "{0}: invalid TranslationUnitReplacements YAML";
+
+constexpr const char *ConflictClusterSummary =
+    "conflict: skipped {0} overlapping replacement(s) at {1}:{2}";
+
+constexpr const char *CannotWriteFile = "cannot write {0}";
+
+constexpr const char *WriteErrorOnFile = "write error on {0}";
+
+constexpr const char *CannotWriteOutput = "cannot write {0}: {1}";
+
+constexpr const char *MissingReplacementFile =
+    "{0}: file does not exist; skipping its replacement(s)";
+
+constexpr const char *CandidateEditMessage = "candidate edit: \"{0}\"";
+
+constexpr const char *ConflictSarifMessage =
+    "{0} overlapping replacement(s) at {1} byte {2} were dropped; resolve "
+    "manually.";
+
+cl::OptionCategory MergeCategory("clang-ssaf-src-edit-merge options");
+
+cl::list<std::string> InputFiles(cl::Positional, cl::OneOrMore,
+                                 cl::desc("<input.yaml>..."),
+                                 cl::cat(MergeCategory));
+
+cl::opt<std::string> OutputFile("o", cl::Required, cl::value_desc("path"),
+                                cl::desc("Output path for the merged YAML."),
+                                cl::cat(MergeCategory));
+
+cl::opt<std::string> SarifConflictsOut(
+    "sarif-conflicts-out", cl::value_desc("path"),
+    cl::desc("Optional path. When supplied, write a SARIF document "
+             "listing conflict clusters dropped from the merged output."),
+    cl::cat(MergeCategory));
+
+/// Read one input YAML into a TranslationUnitReplacements.
+///
+/// Returns true on success. On failure, prints a one-line diagnostic to
+/// stderr and returns false; the caller surfaces this as a non-zero exit.
+bool readInput(llvm::StringRef Path,
+               clang::tooling::TranslationUnitReplacements &Out) {
+  llvm::ErrorOr<std::unique_ptr<llvm::MemoryBuffer>> Buffer =
+      llvm::MemoryBuffer::getFile(Path);
+  if (std::error_code EC = Buffer.getError()) {
+    llvm::errs() << ToolName << ": "
+                 << llvm::formatv(CannotReadInput, Path, EC.message()) << "\n";
+    return false;
+  }
+  llvm::yaml::Input YAML(Buffer.get()->getBuffer());
+  YAML >> Out;
+  if (YAML.error()) {
+    llvm::errs() << ToolName << ": "
+                 << llvm::formatv(InvalidReplacementsYaml, Path) << "\n";
+    return false;
+  }
+  return true;
+}
+
+/// The merged output: a flat list of Replacements spanning every input file.
+///
+/// Deliberately not clang::tooling::TranslationUnitReplacements: that type
+/// represents one TU's replacements, but this represents the drop-all-policy
+/// result of merging N TUs' worth of edits. `MappingTraits` below serializes
+/// this to the identical YAML shape (the same two required keys,
+/// MainSourceFile and Replacements) so clang-apply-replacements — which only
+/// knows how to read that shape — can still consume the output; only the
+/// C++ type stops making a claim about the data it doesn't hold.
+/// MainSourceFile is populated on a best-effort basis (computeMainSourceFile,
+/// below) purely for wire compatibility: nothing in the merge/apply pipeline
+/// reads it back.
+struct MergedReplacements {
+  std::string MainSourceFile;
+  std::vector<clang::tooling::Replacement> Replacements;
+};
+
+/// Compute the shared MainSourceFile across inputs.
+///
+/// Per spec: if every input declares the same MainSourceFile, use that;
+/// otherwise use the empty string.
+std::string computeMainSourceFile(
+    const std::vector<clang::tooling::TranslationUnitReplacements> &TUs) {
+  if (TUs.empty())
+    return "";
+  const std::string &First = TUs.front().MainSourceFile;
+  for (const auto &TU : TUs)
+    if (TU.MainSourceFile != First)
+      return "";
+  return First;
+}
+
+/// Build the conflict cluster list from the merged input key set.
+///
+/// `InputKeysByFile` is every input Replacement with length > 0, grouped by
+/// file. Zero-length insertions are excluded by the caller because they never
+/// overlap anything and are a no-op to apply, so they can never affect this
+/// function's clustering.
+///
+/// A cluster is a maximal connected component of one file's input
+/// replacements whose [offset, offset+length) byte ranges transitively
+/// overlap.
+///
+/// The walk merges into the current cluster whenever
+///   key.offset < lastEnd, where
+///   lastEnd = max(member.offset + member.length) across cluster members.
+/// Otherwise the current cluster closes and a new one opens.
+///
+/// Only clusters of size > 1 are returned; singletons are not conflicts.
+///
+/// Each returned cluster's member list is sorted by (offset, length, text);
+/// the cluster list itself is sorted by (file, min-offset) ascending. This
+/// pins iteration order for both stderr cluster lines and (in a follow-on
+/// task) the SARIF results array.
+std::vector<std::vector<clang::tooling::Replacement>> buildConflictClusters(
+    const std::map<std::string, std::set<clang::tooling::Replacement>>
+        &InputKeysByFile) {
+  std::vector<std::vector<clang::tooling::Replacement>> Clusters;
+  for (auto &Entry : InputKeysByFile) {
+    // Keys is a std::set<Replacement>, so it's already ordered by
+    // Replacement::operator< — the (offset, length, text) order the cluster
+    // walk below needs, since every entry here shares Entry.first as its
+    // file path.
+    auto &Keys = Entry.second;
+
+    std::vector<clang::tooling::Replacement> Current;
+    unsigned LastEnd = 0;
+    auto Flush = [&]() {
+      if (Current.size() > 1)
+        Clusters.push_back(std::move(Current));
+      Current.clear();
+      LastEnd = 0;
+    };
+
+    for (const clang::tooling::Replacement &K : Keys) {
+      if (Current.empty()) {
+        Current.push_back(K);
+        LastEnd = K.getOffset() + K.getLength();
+        continue;
+      }
+      if (K.getOffset() < LastEnd) {
+        Current.push_back(K);
+        LastEnd = std::max(LastEnd, K.getOffset() + K.getLength());
+      } else {
+        Flush();
+        Current.push_back(K);
+        LastEnd = K.getOffset() + K.getLength();
+      }
+    }
+    Flush();
+  }
+
+  // Pin cluster-list order by (file, min-offset) ascending.
+  llvm::sort(Clusters, [](const std::vector<clang::tooling::Replacement> &A,
+                          const std::vector<clang::tooling::Replacement> &B) {
+    if (A.front().getFilePath() != B.front().getFilePath())
+      return A.front().getFilePath() < B.front().getFilePath();
+    return A.front().getOffset() < B.front().getOffset();
+  });
+
+  return Clusters;
+}
+
+/// Emit one stderr line per conflict cluster.
+///
+/// Precondition: `Clusters` is sorted by (file, min-offset) ascending —
+/// guaranteed by buildConflictClusters, the only place Clusters is built.
+void emitConflictClusterLines(
+    const std::vector<std::vector<clang::tooling::Replacement>> &Clusters) {
+  for (const auto &Cluster : Clusters) {
+    llvm::errs() << llvm::formatv(ConflictClusterSummary, Cluster.size(),
+                                  Cluster.front().getFilePath(),
+                                  Cluster.front().getOffset())
+                 << "\n";
+  }
+}
+
+/// Canonicalize a Replacement's `FilePath` into an absolute `file://` URI.
+///
+/// Fallback chain:
+///   1. `llvm::sys::fs::real_path` — resolves symlinks and yields an
+///      absolute path. Only succeeds if the file exists on disk.
+///   2. `llvm::sys::fs::make_absolute` — succeeds for non-existent paths
+///      too; used for synthetic test fixtures whose FilePath may name a
+///      file that the merger never opened.
+///   3. Raw `FilePath` — last-resort fallback if both of the above fail.
+///      Emits a syntactically valid `file://` URI even if the underlying
+///      path is relative, matching the SARIF requirement's "absolute"
+///      promise loosely (downstream tooling that needs strict absolute
+///      URIs SHOULD canonicalize on its end if the disk state permits).
+std::string canonicalizeToFileUri(llvm::StringRef FilePath) {
+  llvm::SmallString<256> Buf;
+  if (!llvm::sys::fs::real_path(FilePath, Buf))
+    return "file://" + llvm::sys::path::convert_to_slash(Buf);
+  Buf.assign(FilePath.begin(), FilePath.end());
+  if (!llvm::sys::fs::make_absolute(Buf))
+    return "file://" + llvm::sys::path::convert_to_slash(Buf);
+  return "file://" + llvm::sys::path::convert_to_slash(FilePath);
+}
+
+/// Emit a SARIF document at `Path` listing every conflict cluster.
+///
+/// `Clusters` SHALL be pre-sorted by `(file, min-offset)` ascending by the
+/// caller; this emitter walks them in order to populate
+/// `runs[0].results[]`. Within each cluster, `relatedLocations[]` is
+/// sorted locally by `(byteLength, candidate-text)` ascending per the
+/// "SARIF conflict report" requirement.
+///
+/// Even when `Clusters` is empty, this writes a well-formed SARIF
+/// document with `runs[0].results: []`. The file's presence is the
+/// "merger ran with conflict reporting requested" signal.
+llvm::Error emitConflictSarif(
+    llvm::StringRef Path,
+    llvm::ArrayRef<std::vector<clang::tooling::Replacement>> Clusters) {
+  llvm::json::Array Results;
+  Results.reserve(Clusters.size());
+
+  for (const auto &Cluster : Clusters) {
+    const clang::tooling::Replacement &Min = Cluster.front();
+    std::string Uri = canonicalizeToFileUri(Min.getFilePath());
+
+    // Re-sort cluster members locally by (byteLength, text) ascending.
+    std::vector<clang::tooling::Replacement> Sorted(Cluster.begin(),
+                                                    Cluster.end());
+    llvm::sort(Sorted, [](const clang::tooling::Replacement &A,
+                          const clang::tooling::Replacement &B) {
+      if (A.getLength() != B.getLength())
+        return A.getLength() < B.getLength();
+      return A.getReplacementText() < B.getReplacementText();
+    });
+
+    llvm::json::Array RelatedLocations;
+    RelatedLocations.reserve(Sorted.size());
+    for (size_t I = 0; I < Sorted.size(); ++I) {
+      const clang::tooling::Replacement &K = Sorted[I];
+      RelatedLocations.push_back(llvm::json::Object{
+          {"id", static_cast<int64_t>(I + 1)},
+          {"physicalLocation",
+           llvm::json::Object{
+               {"artifactLocation", llvm::json::Object{{"uri", Uri}}},
+               {"region",
+                llvm::json::Object{
+                    {"byteOffset", static_cast<int64_t>(K.getOffset())},
+                    {"byteLength", static_cast<int64_t>(K.getLength())}}}}},
+          {"message",
+           llvm::json::Object{{"text", llvm::formatv(CandidateEditMessage,
+                                                     K.getReplacementText())
+                                           .str()}}}});
+    }
+
+    std::string MessageText =
+        llvm::formatv(ConflictSarifMessage, Cluster.size(), Uri,
+                      Min.getOffset())
+            .str();
+
+    Results.push_back(llvm::json::Object{
+        {"ruleId", "clang-reforge-replacement-conflict"},
+        {"level", "error"},
+        {"message", llvm::json::Object{{"text", MessageText}}},
+        {"locations",
+         llvm::json::Array{llvm::json::Object{
+             {"physicalLocation",
+              llvm::json::Object{
+                  {"artifactLocation", llvm::json::Object{{"uri", Uri}}},
+                  {"region", llvm::json::Object{{"byteOffset",
+                                                 static_cast<int64_t>(
+                                                     Min.getOffset())}}}}}}}},
+        {"relatedLocations", std::move(RelatedLocations)}});
+  }
+
+  llvm::json::Value Doc = llvm::json::Object{
+      {"version", "2.1.0"},
+      {"$schema", "https://json.schemastore.org/sarif-2.1.0.json"},
+      {"runs",
+       llvm::json::Array{llvm::json::Object{
+           {"tool",
+            llvm::json::Object{
+                {"driver",
+                 llvm::json::Object{{"name", ToolName},
+                                    {"version", CLANG_VERSION_STRING}}}}},
+           {"results", std::move(Results)}}}}};
+
+  std::error_code EC;
+  llvm::raw_fd_ostream OS(Path, EC, llvm::sys::fs::OF_Text);
+  if (EC)
+    return llvm::createStringError(EC,
+                                   llvm::formatv(CannotWriteFile, Path).str());
+  // Pretty-print with indent 2 via the json::Value format_provider.
+  OS << llvm::formatv("{0:2}", Doc) << "\n";
+  OS.flush();
+  if (OS.has_error())
+    return llvm::createStringError(OS.error(),
+                                   llvm::formatv(WriteErrorOnFile, 
Path).str());
+  return llvm::Error::success();
+}
+
+/// Returns whether `Path`'s parent directory exists, so a bad `-o` or
+/// `--sarif-conflicts-out` path can be rejected before any merge work runs.
+/// A `Path` with no directory component (e.g. a bare file name) is treated
+/// as valid — it names a file in the current directory. This is a
+/// best-effort check, not a substitute for handling the real open() failure:
+/// it cannot catch permission errors or a race between the check and the
+/// eventual write.
+bool parentDirectoryExists(llvm::StringRef Path) {
+  llvm::StringRef Parent = llvm::sys::path::parent_path(Path);
+  return Parent.empty() || llvm::sys::fs::is_directory(Parent);
+}
+
+} // namespace
+
+namespace llvm {
+namespace yaml {
+/// Specialized MappingTraits to describe how a MergedReplacements is
+/// (de)serialized. Mirrors MappingTraits<TranslationUnitReplacements> in
+/// ReplacementsYaml.h exactly, key-for-key, for wire compatibility.
+template <> struct MappingTraits<MergedReplacements> {
+  static void mapping(IO &Io, MergedReplacements &Doc) {
+    Io.mapRequired("MainSourceFile", Doc.MainSourceFile);
+    Io.mapRequired("Replacements", Doc.Replacements);
+  }
+};
+} // namespace yaml
+} // namespace llvm
+
+int main(int argc, const char **argv) {
+  llvm::InitLLVM X(argc, argv);
+  cl::HideUnrelatedOptions(MergeCategory);
+  cl::ParseCommandLineOptions(
+      argc, argv,
+      "clang-ssaf-src-edit-merge: merge per-TU TranslationUnitReplacements "
+      "YAML files for one link unit into a single merged YAML. Does not "
+      "write source files; the apply step is the caller's responsibility.\n");
+
+  // Validate the command-line parameters that can be checked without
+  // reading any input, so a bad -o or --sarif-conflicts-out path is rejected
+  // before the (potentially expensive) merge work below runs.
+  if (!parentDirectoryExists(OutputFile)) {
+    llvm::errs() << ToolName << ": "
+                 << llvm::formatv(CannotWriteFile, OutputFile) << "\n";
+    return 1;
+  }
+  if (!SarifConflictsOut.empty() && !parentDirectoryExists(SarifConflictsOut)) 
{
+    llvm::errs() << ToolName << ": "
+                 << llvm::formatv(CannotWriteFile, SarifConflictsOut) << "\n";
+    return 1;
+  }
+
+  // Read all inputs.
+  std::vector<clang::tooling::TranslationUnitReplacements> TUs;
+  TUs.reserve(InputFiles.size());
+  for (const std::string &Path : InputFiles) {
+    clang::tooling::TranslationUnitReplacements TU;
+    if (!readInput(Path, TU))
+      return 1;
+    TUs.push_back(std::move(TU));
+  }
+
+  // Pre-deduplicate identical replacements across all input TUs.
+  //
+  // This loop keeps a running set of every (file, offset, length, text)
+  // tuple already kept across all TUs and drops
+  // any later Replacement that matches one already kept, so each distinct
+  // Replacement is considered exactly once below. The first occurrence (in
+  // input-file order, then within-file order) wins; later duplicates are
+  // byte-identical to it, so which one is "first" is observationally moot.
+  {
+    std::set<clang::tooling::Replacement> SeenKeys;
+    for (auto &TU : TUs) {
+      std::vector<clang::tooling::Replacement> Unique;
+      Unique.reserve(TU.Replacements.size());
+      for (const clang::tooling::Replacement &R : TU.Replacements) {
+        if (SeenKeys.insert(R).second)
+          Unique.push_back(R);
+      }
+      TU.Replacements = std::move(Unique);
+    }
+  }
+
+  // Determine which input files exist on disk. A Replacement targeting a
+  // file that doesn't exist can never be applied, so every Replacement
+  // targeting that file is excluded from the merged output.
+  std::set<std::string> MissingFiles;
+  {
+    std::set<std::string> AllFiles;
+    for (const auto &TU : TUs)
+      for (const auto &R : TU.Replacements)
+        AllFiles.insert(R.getFilePath().str());
+    for (const std::string &F : AllFiles)
+      if (!llvm::sys::fs::exists(F))
+        MissingFiles.insert(F);
+  }
+  for (const std::string &F : MissingFiles)
+    llvm::errs() << ToolName << ": " << llvm::formatv(MissingReplacementFile, 
F)
+                 << "\n";
+
+  // Split every surviving-candidate Replacement by file. Zero-length
+  // insertions go straight into SurvivorsByFile — they can never overlap
+  // anything, so they're never at risk of being dropped. Length > 0 entries
+  // go into InputKeysByFile, the input to buildConflictClusters, which is
+  // the sole authority on which of them conflict.
+  std::map<std::string, std::set<clang::tooling::Replacement>> SurvivorsByFile;
+  std::map<std::string, std::set<clang::tooling::Replacement>> InputKeysByFile;
+  for (const auto &TU : TUs) {
+    for (const auto &R : TU.Replacements) {
+      if (MissingFiles.count(R.getFilePath().str()))
+        continue;
+      if (R.getLength() == 0)
+        SurvivorsByFile[R.getFilePath().str()].insert(R);
+      else
+        InputKeysByFile[R.getFilePath().str()].insert(R);
+    }
+  }
+
+  // Build conflict clusters — the sole authority on both what gets dropped
+  // and what gets reported. There is no separate merge step to disagree
+  // with it.
+  std::vector<std::vector<clang::tooling::Replacement>> Clusters =
+      buildConflictClusters(InputKeysByFile);
+
+  // Every Replacement that's a member of a (size > 1) cluster is dropped;
+  // everything else in InputKeysByFile survives into SurvivorsByFile.
+  std::set<clang::tooling::Replacement> ClusterMembers;
+  for (const auto &Cluster : Clusters)
+    for (const clang::tooling::Replacement &K : Cluster)
+      ClusterMembers.insert(K);
+  for (auto &Entry : InputKeysByFile)
+    for (const clang::tooling::Replacement &R : Entry.second)
+      if (!ClusterMembers.count(R))
+        SurvivorsByFile[Entry.first].insert(R);
+
+  // Flatten SurvivorsByFile into the merged output. Iterating a std::map of
+  // std::sets yields (file, then offset/length/text) order deterministically,
+  // regardless of argv or input-file order.
+  MergedReplacements OutDoc;
+  OutDoc.MainSourceFile = computeMainSourceFile(TUs);
+  for (auto &Entry : SurvivorsByFile)
+    for (const clang::tooling::Replacement &R : Entry.second)
+      OutDoc.Replacements.push_back(R);
+
+  // Emit stderr cluster lines. Clusters was sorted by (file, min-offset)
+  // ascending inside buildConflictClusters.
+  emitConflictClusterLines(Clusters);
+
+  // When --sarif-conflicts-out=<path> was supplied, write the SARIF
+  // document. An empty Clusters still produces a well-formed SARIF with
+  // results: [] — the file's presence is the signal that conflict
+  // reporting was requested. Flag-omitted skips emission entirely; no file
+  // is created at any path.
+  if (!SarifConflictsOut.empty()) {
+    if (llvm::Error E = emitConflictSarif(SarifConflictsOut, Clusters)) {
+      llvm::errs() << ToolName << ": " << llvm::toString(std::move(E)) << "\n";
+      return 1;
+    }
+  }
+
+  // Write merged YAML (truncate-and-overwrite per spec).
+  std::error_code EC;
+  llvm::raw_fd_ostream OutStream(OutputFile, EC, llvm::sys::fs::OF_Text);
+  if (EC) {
+    llvm::errs() << ToolName << ": "
+                 << llvm::formatv(CannotWriteOutput, OutputFile, EC.message())
+                 << "\n";
+    return 1;
+  }
+  llvm::yaml::Output YAML(OutStream);
+  YAML << OutDoc;
+  OutStream.flush();
+  if (OutStream.has_error()) {
+    llvm::errs() << ToolName << ": "
+                 << llvm::formatv(WriteErrorOnFile, OutputFile) << "\n";
+    return 1;
+  }
+
+  return 0;
+}

diff  --git a/clang-tools-extra/test/CMakeLists.txt 
b/clang-tools-extra/test/CMakeLists.txt
index 22f227a891f82..49794ff5111a3 100644
--- a/clang-tools-extra/test/CMakeLists.txt
+++ b/clang-tools-extra/test/CMakeLists.txt
@@ -33,6 +33,7 @@ set(CLANG_TOOLS_TEST_DEPS
   clang-move
   clang-query
   clang-reorder-fields
+  clang-ssaf-src-edit-merge
   find-all-symbols
   modularize
   pp-trace

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/conflict-text-q-offset-30.tpl.yaml
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/conflict-text-q-offset-30.tpl.yaml
new file mode 100644
index 0000000000000..0e263528352da
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/conflict-text-q-offset-30.tpl.yaml
@@ -0,0 +1,8 @@
+---
+MainSourceFile:  '@FOO@'
+Replacements:
+  - FilePath:        '@FOO@'
+    Offset:          30
+    Length:          3
+    ReplacementText: 'Q'
+...

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/conflict-text-y.tpl.yaml
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/conflict-text-y.tpl.yaml
new file mode 100644
index 0000000000000..315069302d5af
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/conflict-text-y.tpl.yaml
@@ -0,0 +1,8 @@
+---
+MainSourceFile:  '@FOO@'
+Replacements:
+  - FilePath:        '@FOO@'
+    Offset:          10
+    Length:          3
+    ReplacementText: 'Y'
+...

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/conflict-text-z.tpl.yaml
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/conflict-text-z.tpl.yaml
new file mode 100644
index 0000000000000..b907f0a3dad9a
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/conflict-text-z.tpl.yaml
@@ -0,0 +1,8 @@
+---
+MainSourceFile:  '@FOO@'
+Replacements:
+  - FilePath:        '@FOO@'
+    Offset:          10
+    Length:          3
+    ReplacementText: 'Z'
+...

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/empty.tpl.yaml 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/empty.tpl.yaml
new file mode 100644
index 0000000000000..cb2997ae678b4
--- /dev/null
+++ b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/empty.tpl.yaml
@@ -0,0 +1,4 @@
+---
+MainSourceFile:  '@FOO@'
+Replacements:    []
+...

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/internal-conflict.tpl.yaml
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/internal-conflict.tpl.yaml
new file mode 100644
index 0000000000000..cf7914d3ddca9
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/internal-conflict.tpl.yaml
@@ -0,0 +1,12 @@
+---
+MainSourceFile:  '@FOO@'
+Replacements:
+  - FilePath:        '@FOO@'
+    Offset:          10
+    Length:          3
+    ReplacementText: 'X'
+  - FilePath:        '@FOO@'
+    Offset:          10
+    Length:          3
+    ReplacementText: 'Y'
+...

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/non-overlapping-a.tpl.yaml
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/non-overlapping-a.tpl.yaml
new file mode 100644
index 0000000000000..b461ee5b741a1
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/non-overlapping-a.tpl.yaml
@@ -0,0 +1,8 @@
+---
+MainSourceFile:  '@FOO@'
+Replacements:
+  - FilePath:        '@FOO@'
+    Offset:          10
+    Length:          3
+    ReplacementText: 'X'
+...

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/non-overlapping-b.tpl.yaml
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/non-overlapping-b.tpl.yaml
new file mode 100644
index 0000000000000..6dc742bfeb36f
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/non-overlapping-b.tpl.yaml
@@ -0,0 +1,8 @@
+---
+MainSourceFile:  '@FOO@'
+Replacements:
+  - FilePath:        '@FOO@'
+    Offset:          20
+    Length:          5
+    ReplacementText: 'Y'
+...

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/range-overlap-cluster-of-three.tpl.yaml
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/range-overlap-cluster-of-three.tpl.yaml
new file mode 100644
index 0000000000000..f506d7de270c9
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/range-overlap-cluster-of-three.tpl.yaml
@@ -0,0 +1,16 @@
+---
+MainSourceFile:  '@FOO@'
+Replacements:
+  - FilePath:        '@FOO@'
+    Offset:          10
+    Length:          5
+    ReplacementText: 'X'
+  - FilePath:        '@FOO@'
+    Offset:          12
+    Length:          8
+    ReplacementText: 'Y'
+  - FilePath:        '@FOO@'
+    Offset:          18
+    Length:          3
+    ReplacementText: 'Z'
+...

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/range-overlap-distinct-offsets.tpl.yaml
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/range-overlap-distinct-offsets.tpl.yaml
new file mode 100644
index 0000000000000..808be0108fd70
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/range-overlap-distinct-offsets.tpl.yaml
@@ -0,0 +1,12 @@
+---
+MainSourceFile:  '@FOO@'
+Replacements:
+  - FilePath:        '@FOO@'
+    Offset:          10
+    Length:          5
+    ReplacementText: 'X'
+  - FilePath:        '@FOO@'
+    Offset:          12
+    Length:          3
+    ReplacementText: 'Y'
+...

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-non-zero-length-replacement.tpl.yaml
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-non-zero-length-replacement.tpl.yaml
new file mode 100644
index 0000000000000..a8a7cbfefc3e8
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-non-zero-length-replacement.tpl.yaml
@@ -0,0 +1,8 @@
+---
+MainSourceFile:  '@FOO@'
+Replacements:
+  - FilePath:        '@FOO@'
+    Offset:          10
+    Length:          7
+    ReplacementText: 'foo'
+...

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-replacement-offset-30.tpl.yaml
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-replacement-offset-30.tpl.yaml
new file mode 100644
index 0000000000000..ba031fead786a
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-replacement-offset-30.tpl.yaml
@@ -0,0 +1,8 @@
+---
+MainSourceFile:  '@FOO@'
+Replacements:
+  - FilePath:        '@FOO@'
+    Offset:          30
+    Length:          3
+    ReplacementText: 'P'
+...

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-replacement.tpl.yaml
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-replacement.tpl.yaml
new file mode 100644
index 0000000000000..b461ee5b741a1
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-replacement.tpl.yaml
@@ -0,0 +1,8 @@
+---
+MainSourceFile:  '@FOO@'
+Replacements:
+  - FilePath:        '@FOO@'
+    Offset:          10
+    Length:          3
+    ReplacementText: 'X'
+...

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-zero-length-insert.tpl.yaml
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-zero-length-insert.tpl.yaml
new file mode 100644
index 0000000000000..115a18ad2b194
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-zero-length-insert.tpl.yaml
@@ -0,0 +1,8 @@
+---
+MainSourceFile:  '@FOO@'
+Replacements:
+  - FilePath:        '@FOO@'
+    Offset:          10
+    Length:          0
+    ReplacementText: '.data()'
+...

diff  --git a/clang-tools-extra/test/clang-ssaf-src-edit-merge/bad-args.test 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/bad-args.test
new file mode 100644
index 0000000000000..f182f107ec289
--- /dev/null
+++ b/clang-tools-extra/test/clang-ssaf-src-edit-merge/bad-args.test
@@ -0,0 +1,10 @@
+# Required-arg checks for clang-ssaf-src-edit-merge.
+
+# Missing -o.
+# RUN: not clang-ssaf-src-edit-merge dummy.yaml 2>&1 | FileCheck %s 
--check-prefix=NEED-O
+# NEED-O: -o
+# NEED-O-SAME: must be specified
+
+# Missing positional inputs.
+# RUN: not clang-ssaf-src-edit-merge -o /tmp/out.yaml 2>&1 | FileCheck %s 
--check-prefix=NEED-INPUTS
+# NEED-INPUTS: at least 1 positional argument

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/conflict-preserves-unrelated-zero-length-insert.test
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/conflict-preserves-unrelated-zero-length-insert.test
new file mode 100644
index 0000000000000..871498e4d3e33
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/conflict-preserves-unrelated-zero-length-insert.test
@@ -0,0 +1,44 @@
+# A zero-length insertion at the same offset as an
+# unrelated conflict cluster must survive drop-all filtering.
+#
+# Three inputs target foo.cpp offset 10: a zero-length insertion
+# ('.data()', out of scope for drop-all per spec) plus two conflicting
+# length-3 replacements ('X' and 'Y'). The insertion never overlaps
+# either replacement (zero-length ranges never overlap under
+# Range::overlapsWith), so it is not part of the {X, Y} conflict cluster
+# and must be preserved in the merged output even though it shares the
+# cluster's (file, offset) coordinate.
+#
+# Drop-all filtering must key removal on the full replacement identity
+# (file, offset, length, text), not just (file, offset), or it will
+# collaterally delete this unrelated insertion.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# RUN: cp %S/Inputs/single-zero-length-insert.tpl.yaml %t.dir/a.yaml
+# RUN: cp %S/Inputs/single-replacement.tpl.yaml %t.dir/b.yaml
+# RUN: cp %S/Inputs/conflict-text-y.tpl.yaml %t.dir/c.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" \
+# RUN:           %t.dir/a.yaml %t.dir/b.yaml %t.dir/c.yaml
+
+# RUN: clang-ssaf-src-edit-merge \
+# RUN:     -o %t.dir/merged.yaml %t.dir/a.yaml %t.dir/b.yaml %t.dir/c.yaml \
+# RUN:   2> %t.dir/stderr.txt
+
+# The zero-length '.data()' insertion survives; the conflicting X/Y
+# replacements are both dropped.
+# RUN: FileCheck --input-file=%t.dir/merged.yaml %s
+# CHECK: Replacements:
+# CHECK: Length:          0
+# CHECK-NEXT: ReplacementText: '.data()'
+# CHECK-NOT: ReplacementText:
+# CHECK: ...
+
+# Cluster summary line still reports the X/Y conflict at foo.cpp:10.
+# RUN: FileCheck --input-file=%t.dir/stderr.txt --check-prefix=CONFLICT %s
+# CONFLICT: conflict:
+# CONFLICT-SAME: skipped
+# CONFLICT-SAME: 2
+# CONFLICT-SAME: foo.cpp:10

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/conflict-stderr-without-sarif.test
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/conflict-stderr-without-sarif.test
new file mode 100644
index 0000000000000..1067c49e37a54
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/conflict-stderr-without-sarif.test
@@ -0,0 +1,29 @@
+# Same conflicting inputs as two-input-conflict.test, but the user does
+# NOT pass --sarif-conflicts-out=. Stderr cluster line is still emitted
+# and drop-all still applies; no SARIF file is created at any path.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# RUN: cp %S/Inputs/single-replacement.tpl.yaml %t.dir/a.yaml
+# RUN: cp %S/Inputs/conflict-text-y.tpl.yaml %t.dir/b.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml %t.dir/b.yaml
+
+# RUN: clang-ssaf-src-edit-merge \
+# RUN:     -o %t.dir/merged.yaml %t.dir/a.yaml %t.dir/b.yaml \
+# RUN:   2> %t.dir/stderr.txt
+
+# No SARIF file at the path a paired test would have used.
+# RUN: ! test -e %t.dir/conflicts.sarif
+
+# Drop-all still applies even without the SARIF flag.
+# RUN: FileCheck --input-file=%t.dir/merged.yaml --check-prefix=MERGED %s
+# MERGED-NOT: ReplacementText:
+
+# Stderr cluster line is still emitted (back-compat regression guard).
+# RUN: FileCheck --input-file=%t.dir/stderr.txt --check-prefix=CONFLICT %s
+# CONFLICT: conflict:
+# CONFLICT-SAME: skipped
+# CONFLICT-SAME: 2
+# CONFLICT-SAME: foo.cpp:10

diff  --git a/clang-tools-extra/test/clang-ssaf-src-edit-merge/empty-input.test 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/empty-input.test
new file mode 100644
index 0000000000000..ba08d60ade242
--- /dev/null
+++ b/clang-tools-extra/test/clang-ssaf-src-edit-merge/empty-input.test
@@ -0,0 +1,24 @@
+# When all inputs have empty Replacements sequences, the output is also
+# a valid TranslationUnitReplacements with an empty Replacements sequence.
+# Per spec the MainSourceFile is the shared value when all inputs agree.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# RUN: cp %S/Inputs/empty.tpl.yaml %t.dir/a.yaml
+# RUN: cp %S/Inputs/empty.tpl.yaml %t.dir/b.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml %t.dir/b.yaml
+
+# RUN: clang-ssaf-src-edit-merge -o %t.dir/merged.yaml %t.dir/a.yaml 
%t.dir/b.yaml \
+# RUN:   2> %t.dir/stderr.txt
+
+# RUN: FileCheck --input-file=%t.dir/merged.yaml %s
+# CHECK: MainSourceFile:
+# CHECK-SAME: foo.cpp
+# CHECK: Replacements:
+# CHECK-SAME: []
+# CHECK-NOT: FilePath:
+
+# RUN: FileCheck --input-file=%t.dir/stderr.txt --allow-empty 
--check-prefix=NOSTDERR %s
+# NOSTDERR-NOT: conflict:

diff  --git a/clang-tools-extra/test/clang-ssaf-src-edit-merge/help.test 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/help.test
new file mode 100644
index 0000000000000..9842c1e58f5f5
--- /dev/null
+++ b/clang-tools-extra/test/clang-ssaf-src-edit-merge/help.test
@@ -0,0 +1,9 @@
+# --help smoke test for clang-ssaf-src-edit-merge.
+
+# RUN: clang-ssaf-src-edit-merge --help 2>&1 | FileCheck %s
+
+# CHECK: clang-ssaf-src-edit-merge
+# CHECK: USAGE
+# CHECK-SAME: input.yaml
+# CHECK: -o
+# CHECK-SAME: Output path for the merged YAML

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/identical-overlap.test 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/identical-overlap.test
new file mode 100644
index 0000000000000..99427f0459b26
--- /dev/null
+++ b/clang-tools-extra/test/clang-ssaf-src-edit-merge/identical-overlap.test
@@ -0,0 +1,26 @@
+# Identical replacements at the same (file, offset, length, text) from two
+# inputs collapse to one in the merged output. No "conflict:" line because
+# this is a textbook dedup, not a conflict.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# Use the same single-replacement template for both inputs.
+# RUN: cp %S/Inputs/single-replacement.tpl.yaml %t.dir/a.yaml
+# RUN: cp %S/Inputs/single-replacement.tpl.yaml %t.dir/b.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml %t.dir/b.yaml
+
+# RUN: clang-ssaf-src-edit-merge -o %t.dir/merged.yaml %t.dir/a.yaml 
%t.dir/b.yaml \
+# RUN:   2> %t.dir/stderr.txt
+
+# Exactly one Replacement entry survived.
+# RUN: FileCheck --input-file=%t.dir/merged.yaml %s
+# CHECK: Replacements:
+# CHECK: ReplacementText: X
+# CHECK-NOT: ReplacementText:
+# CHECK: ...
+
+# No tool-level conflict line.
+# RUN: FileCheck --input-file=%t.dir/stderr.txt --allow-empty 
--check-prefix=NOSTDERR %s
+# NOSTDERR-NOT: conflict:

diff  --git a/clang-tools-extra/test/clang-ssaf-src-edit-merge/lit.local.cfg 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/lit.local.cfg
new file mode 100644
index 0000000000000..0ed7eee8f8bf4
--- /dev/null
+++ b/clang-tools-extra/test/clang-ssaf-src-edit-merge/lit.local.cfg
@@ -0,0 +1 @@
+config.suffixes = [".test"]

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/malformed-yaml.test 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/malformed-yaml.test
new file mode 100644
index 0000000000000..0ccc6147219a6
--- /dev/null
+++ b/clang-tools-extra/test/clang-ssaf-src-edit-merge/malformed-yaml.test
@@ -0,0 +1,19 @@
+# Malformed-YAML and empty-file inputs cause a non-zero exit with a clear
+# stderr message. Verifies the spec's "an input file [...] does not parse
+# as TranslationUnitReplacements" error path.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+
+# Input is not valid YAML at all.
+# RUN: echo '{ this is not yaml ::: %@ }' > %t.dir/garbage.yaml
+# RUN: not clang-ssaf-src-edit-merge -o %t.dir/merged.yaml %t.dir/garbage.yaml 
\
+# RUN:   2> %t.dir/stderr.txt
+# RUN: FileCheck --input-file=%t.dir/stderr.txt --check-prefix=BAD %s
+# BAD: clang-ssaf-src-edit-merge:
+# BAD-SAME: garbage.yaml
+
+# An empty file: the YAML traits will parse to a default-initialized
+# TranslationUnitReplacements with no error. We accept that; the merged
+# output is then empty and the tool exits 0 (covered indirectly elsewhere).
+# Behavior on truly malformed YAML is the load-bearing test here.

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/no-conflict-sarif-empty.test 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/no-conflict-sarif-empty.test
new file mode 100644
index 0000000000000..9575025ee00f3
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/no-conflict-sarif-empty.test
@@ -0,0 +1,35 @@
+# No-conflict input with --sarif-conflicts-out=: SARIF is still written but
+# with results: [] (empty array). The merged YAML retains both replacements.
+# Tool exits 0; stderr contains no `conflict:` line.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# RUN: cp %S/Inputs/non-overlapping-a.tpl.yaml %t.dir/a.yaml
+# RUN: cp %S/Inputs/non-overlapping-b.tpl.yaml %t.dir/b.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml %t.dir/b.yaml
+
+# RUN: clang-ssaf-src-edit-merge \
+# RUN:     -o %t.dir/merged.yaml \
+# RUN:     --sarif-conflicts-out=%t.dir/conflicts.sarif \
+# RUN:     %t.dir/a.yaml %t.dir/b.yaml 2> %t.dir/stderr.txt
+
+# Both inputs' replacements survive unchanged.
+# RUN: FileCheck --input-file=%t.dir/merged.yaml --check-prefix=MERGED %s
+# MERGED-DAG: ReplacementText: X
+# MERGED-DAG: ReplacementText: Y
+
+# SARIF was written with version 2.1.0 and an empty results array.
+# llvm::json::Object emits keys alphabetically, so $schema, results,
+# tool come first inside runs[0] and version is the last top-level key.
+# RUN: FileCheck --input-file=%t.dir/conflicts.sarif --check-prefix=SARIF %s
+# SARIF: "$schema": "https://json.schemastore.org/sarif-2.1.0.json";
+# SARIF: "results": []
+# SARIF: "name": "clang-ssaf-src-edit-merge"
+# SARIF: "version": "2.1.0"
+
+# Stderr contains no `conflict:` line.
+# RUN: FileCheck --input-file=%t.dir/stderr.txt --check-prefix=STDERR \
+# RUN:           --allow-empty %s
+# STDERR-NOT: conflict:

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/non-overlapping.test 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/non-overlapping.test
new file mode 100644
index 0000000000000..13310acae189e
--- /dev/null
+++ b/clang-tools-extra/test/clang-ssaf-src-edit-merge/non-overlapping.test
@@ -0,0 +1,31 @@
+# Non-overlapping replacements from two inputs both survive the merge.
+# Conflicts are detected per-(file, offset) so disjoint offsets yield no
+# tool-level "conflict:" line, and exit is 0.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# Real source file required so groupReplacements doesn't drop the entries.
+# 80 bytes is plenty for both Offset=10 and Offset=20.
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# RUN: cp %S/Inputs/non-overlapping-a.tpl.yaml %t.dir/a.yaml
+# RUN: cp %S/Inputs/non-overlapping-b.tpl.yaml %t.dir/b.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml %t.dir/b.yaml
+
+# RUN: clang-ssaf-src-edit-merge -o %t.dir/merged.yaml %t.dir/a.yaml 
%t.dir/b.yaml \
+# RUN:   2> %t.dir/stderr.txt
+
+# Output contains both replacements.
+# RUN: FileCheck --input-file=%t.dir/merged.yaml --check-prefix=MERGED %s
+# MERGED: MainSourceFile:
+# MERGED-SAME: foo.cpp
+# MERGED: Offset:
+# MERGED-SAME: 10
+# MERGED: ReplacementText: X
+# MERGED: Offset:
+# MERGED-SAME: 20
+# MERGED: ReplacementText: Y
+
+# No "conflict:" lines on stderr.
+# RUN: FileCheck --input-file=%t.dir/stderr.txt --allow-empty 
--check-prefix=NOSTDERR %s
+# NOSTDERR-NOT: conflict:

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/order-invariance.test 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/order-invariance.test
new file mode 100644
index 0000000000000..9a10eba5c7fc1
--- /dev/null
+++ b/clang-tools-extra/test/clang-ssaf-src-edit-merge/order-invariance.test
@@ -0,0 +1,19 @@
+# The merge SHALL be order-invariant: permuting the input argv yields a
+# byte-equivalent output (modulo internal sort stabilization). Verifies
+# the deterministic sort in flattenFileChanges and the dedup-by-key
+# logic don't inadvertently depend on input order.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# RUN: cp %S/Inputs/non-overlapping-a.tpl.yaml %t.dir/a.yaml
+# RUN: cp %S/Inputs/non-overlapping-b.tpl.yaml %t.dir/b.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml %t.dir/b.yaml
+
+# Run the tool twice with 
diff erent argv orders.
+# RUN: clang-ssaf-src-edit-merge -o %t.dir/merged-ab.yaml %t.dir/a.yaml 
%t.dir/b.yaml
+# RUN: clang-ssaf-src-edit-merge -o %t.dir/merged-ba.yaml %t.dir/b.yaml 
%t.dir/a.yaml
+
+# Outputs are byte-identical.
+# RUN: 
diff  %t.dir/merged-ab.yaml %t.dir/merged-ba.yaml

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/parent-dir-missing.test 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/parent-dir-missing.test
new file mode 100644
index 0000000000000..1001363d1302e
--- /dev/null
+++ b/clang-tools-extra/test/clang-ssaf-src-edit-merge/parent-dir-missing.test
@@ -0,0 +1,19 @@
+# Output path's parent directory does not exist → tool exits non-zero
+# with a clear "cannot write" diagnostic naming the path.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+# RUN: cp %S/Inputs/single-replacement.tpl.yaml %t.dir/a.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml
+
+# Note: %t.dir/no-such-subdir does NOT exist. The output path therefore
+# cannot be opened and the tool must fail.
+# RUN: not clang-ssaf-src-edit-merge \
+# RUN:     -o %t.dir/no-such-subdir/merged.yaml %t.dir/a.yaml \
+# RUN:   2> %t.dir/stderr.txt
+
+# RUN: FileCheck --input-file=%t.dir/stderr.txt %s
+# CHECK: clang-ssaf-src-edit-merge:
+# CHECK-SAME: cannot write
+# CHECK-SAME: no-such-subdir

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/pre-dedup-identical-non-zero-length.test
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/pre-dedup-identical-non-zero-length.test
new file mode 100644
index 0000000000000..4444ea0703445
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/pre-dedup-identical-non-zero-length.test
@@ -0,0 +1,28 @@
+# Identical non-zero-length replacements at the same (file, offset, length, 
text)
+# from two inputs collapse to ONE entry. The pre-existing 
identical-overlap.test
+# also covers this through library-side overlap detection; this test pins the
+# pre-dedup pass's contract that the dedup applies regardless of length.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# Two inputs with byte-identical Length: 7 replacements at offset 10.
+# RUN: cp %S/Inputs/single-non-zero-length-replacement.tpl.yaml %t.dir/a.yaml
+# RUN: cp %S/Inputs/single-non-zero-length-replacement.tpl.yaml %t.dir/b.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml %t.dir/b.yaml
+
+# RUN: clang-ssaf-src-edit-merge -o %t.dir/merged.yaml %t.dir/a.yaml 
%t.dir/b.yaml \
+# RUN:   2> %t.dir/stderr.txt
+
+# Exactly ONE Replacement entry survived.
+# RUN: FileCheck --input-file=%t.dir/merged.yaml %s
+# CHECK: Replacements:
+# CHECK: Length:          7
+# CHECK-NEXT: ReplacementText: foo
+# CHECK-NOT: ReplacementText:
+# CHECK: ...
+
+# No tool-level conflict line.
+# RUN: FileCheck --input-file=%t.dir/stderr.txt --allow-empty 
--check-prefix=NOSTDERR %s
+# NOSTDERR-NOT: conflict:

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/pre-dedup-zero-length-insert.test
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/pre-dedup-zero-length-insert.test
new file mode 100644
index 0000000000000..a5f9b93b92715
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/pre-dedup-zero-length-insert.test
@@ -0,0 +1,33 @@
+# Identical zero-length insertions at the same (file, offset, text) from two
+# inputs (e.g., a `.data()` rewrite in a header included by N TUs) collapse
+# to ONE entry in the merged output, not concatenated into '.data().data()'.
+# This pins the libJP2-trigger shape from iter-09: the runaway-chain bug
+# where clang-apply-replacements' groupReplacements skipped dedup for plain
+# TUReplacements and AtomicChange::replace stacked the duplicates.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# Two inputs with byte-identical zero-length insertions at offset 10.
+# RUN: cp %S/Inputs/single-zero-length-insert.tpl.yaml %t.dir/a.yaml
+# RUN: cp %S/Inputs/single-zero-length-insert.tpl.yaml %t.dir/b.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml %t.dir/b.yaml
+
+# RUN: clang-ssaf-src-edit-merge -o %t.dir/merged.yaml %t.dir/a.yaml 
%t.dir/b.yaml \
+# RUN:   2> %t.dir/stderr.txt
+
+# Exactly ONE Replacement entry survived — the duplicate from b.yaml is
+# collapsed by the pre-dedup pass before mergeAndDeduplicate runs.
+# Pre-fix this would have produced two stacked inserts whose AtomicChange
+# concatenation yielded '.data().data()'.
+# RUN: FileCheck --input-file=%t.dir/merged.yaml %s
+# CHECK: Replacements:
+# CHECK: Length:          0
+# CHECK-NEXT: ReplacementText: '.data()'
+# CHECK-NOT: ReplacementText:
+# CHECK: ...
+
+# No tool-level conflict line — identical entries are not conflicts.
+# RUN: FileCheck --input-file=%t.dir/stderr.txt --allow-empty 
--check-prefix=NOSTDERR %s
+# NOSTDERR-NOT: conflict:

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/range-overlap-cluster-of-three-sarif.test
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/range-overlap-cluster-of-three-sarif.test
new file mode 100644
index 0000000000000..e4b13b874d5e1
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/range-overlap-cluster-of-three-sarif.test
@@ -0,0 +1,63 @@
+## Cluster-of-3+ range-overlap regression guard. One input with three
+## replacements: (10, 5, "X"), (12, 8, "Y"), (18, 3, "Z"). Byte ranges
+## are [10, 15), [12, 20), and [18, 21).
+##
+## - (10, 5) and (12, 8) overlap DIRECTLY on bytes 12..14.
+## - (12, 8) and (18, 3) overlap DIRECTLY on bytes 18..19.
+## - (10, 5) and (18, 3) do NOT overlap directly (18 >= 15) but DO
+##   overlap TRANSITIVELY via (12, 8). The cluster algorithm merges
+##   them via lastEnd propagation: after (10, 5) lastEnd is 15;
+##   (12, 8) joins because 12 < 15, lastEnd becomes max(15, 20) = 20;
+##   (18, 3) joins because 18 < 20.
+##
+## This is the regression guard for transitive merging — it cannot be
+## exercised by any pairwise-only fixture.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# RUN: cp %S/Inputs/range-overlap-cluster-of-three.tpl.yaml %t.dir/a.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml
+
+# RUN: clang-ssaf-src-edit-merge \
+# RUN:     -o %t.dir/merged.yaml \
+# RUN:     --sarif-conflicts-out=%t.dir/conflicts.sarif \
+# RUN:     %t.dir/a.yaml 2> %t.dir/stderr.txt
+
+## Drop-all: zero replacements survive at offsets 10, 12, OR 18.
+# RUN: FileCheck --input-file=%t.dir/merged.yaml --check-prefix=MERGED %s
+# MERGED-NOT: ReplacementText:
+
+## One report-side result at the cluster's minimum offset (10).
+## relatedLocations sorted by (byteLength, text) ascending so the
+## length-3 "Z" entry comes first, then length-5 "X", then length-8
+## "Y". Within each entry json::Object emits keys in alphabetical
+## order: id, message, physicalLocation; within region: byteLength,
+## byteOffset.
+# RUN: FileCheck --input-file=%t.dir/conflicts.sarif --check-prefix=SARIF %s
+# SARIF:      "results":
+# SARIF:        "level": "error"
+# SARIF:        "byteOffset": 10
+# SARIF:        "relatedLocations":
+# SARIF:          "id": 1
+# SARIF:          "text": "candidate edit: \"Z\""
+# SARIF:          "byteLength": 3
+# SARIF-NEXT:          "byteOffset": 18
+# SARIF:          "id": 2
+# SARIF:          "text": "candidate edit: \"X\""
+# SARIF:          "byteLength": 5
+# SARIF-NEXT:          "byteOffset": 10
+# SARIF:          "id": 3
+# SARIF:          "text": "candidate edit: \"Y\""
+# SARIF:          "byteLength": 8
+# SARIF-NEXT:          "byteOffset": 12
+# SARIF:        "ruleId": "clang-reforge-replacement-conflict"
+
+## Stderr: one cluster line at foo.cpp:10 (cluster min offset),
+## skipped 3 (full cluster size, not size - 1).
+# RUN: FileCheck --input-file=%t.dir/stderr.txt --check-prefix=CONFLICT %s
+# CONFLICT: conflict:
+# CONFLICT-SAME: skipped
+# CONFLICT-SAME: 3
+# CONFLICT-SAME: foo.cpp:10

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/range-overlap-conflict-sarif.test
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/range-overlap-conflict-sarif.test
new file mode 100644
index 0000000000000..d5fe203df7f44
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/range-overlap-conflict-sarif.test
@@ -0,0 +1,53 @@
+# Range-overlap conflict with distinct start offsets — one input
+# contains two replacements at (10, 5, "X") and (12, 3, "Y"). Byte
+# ranges [10, 15) and [12, 15) overlap on bytes 12..14, forming one
+# cluster of two. Drop-all removes both members at their respective
+# offsets; SARIF has one result at byteOffset 10 (cluster min) with two
+# relatedLocations sorted by (byteLength, text) ascending so the
+# length-3 "Y" entry precedes the length-5 "X" entry.
+#
+# This is the mandatory regression guard for the range-overlap cluster
+# definition — it cannot be exercised by any same-offset fixture.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# RUN: cp %S/Inputs/range-overlap-distinct-offsets.tpl.yaml %t.dir/a.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml
+
+# RUN: clang-ssaf-src-edit-merge \
+# RUN:     -o %t.dir/merged.yaml \
+# RUN:     --sarif-conflicts-out=%t.dir/conflicts.sarif \
+# RUN:     %t.dir/a.yaml 2> %t.dir/stderr.txt
+
+# Drop-all: zero replacements survive at offsets 10 OR 12.
+# RUN: FileCheck --input-file=%t.dir/merged.yaml --check-prefix=MERGED %s
+# MERGED-NOT: ReplacementText:
+
+# Run the SARIF assertions. One result at byteOffset 10 (cluster min).
+# relatedLocations is sorted by (byteLength, text) so length=3 "Y" comes
+# before length=5 "X". Within each entry json::Object emits keys in
+# alphabetical order: id, message, physicalLocation; within region:
+# byteLength, byteOffset.
+# RUN: FileCheck --input-file=%t.dir/conflicts.sarif --check-prefix=SARIF %s
+# SARIF:      "results":
+# SARIF:        "level": "error"
+# SARIF:        "byteOffset": 10
+# SARIF:        "relatedLocations":
+# SARIF:          "id": 1
+# SARIF:          "text": "candidate edit: \"Y\""
+# SARIF:          "byteLength": 3
+# SARIF-NEXT:          "byteOffset": 12
+# SARIF:          "id": 2
+# SARIF:          "text": "candidate edit: \"X\""
+# SARIF:          "byteLength": 5
+# SARIF-NEXT:          "byteOffset": 10
+# SARIF:        "ruleId": "clang-reforge-replacement-conflict"
+
+# Stderr: one cluster line at foo.cpp:10 (cluster min offset).
+# RUN: FileCheck --input-file=%t.dir/stderr.txt --check-prefix=CONFLICT %s
+# CONFLICT: conflict:
+# CONFLICT-SAME: skipped
+# CONFLICT-SAME: 2
+# CONFLICT-SAME: foo.cpp:10

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/sarif-out-parent-dir-missing.test
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/sarif-out-parent-dir-missing.test
new file mode 100644
index 0000000000000..0efdbd77d5377
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/sarif-out-parent-dir-missing.test
@@ -0,0 +1,21 @@
+# --sarif-conflicts-out=<path> where <path>'s parent directory does not
+# exist → tool exits non-zero with a one-line stderr diagnostic naming
+# the SARIF path. Mirrors parent-dir-missing.test for -o.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+# RUN: cp %S/Inputs/single-replacement.tpl.yaml %t.dir/a.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml
+
+# Note: %t.dir/nonexistent-subdir does NOT exist.
+# RUN: not clang-ssaf-src-edit-merge \
+# RUN:     -o %t.dir/merged.yaml \
+# RUN:     --sarif-conflicts-out=%t.dir/nonexistent-subdir/conflicts.sarif \
+# RUN:     %t.dir/a.yaml 2> %t.dir/stderr.txt
+
+# RUN: FileCheck --input-file=%t.dir/stderr.txt %s
+# CHECK: clang-ssaf-src-edit-merge:
+# CHECK-SAME: cannot write
+# CHECK-SAME: nonexistent-subdir
+# CHECK-SAME: conflicts.sarif

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/single-input-internal-conflict.test
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/single-input-internal-conflict.test
new file mode 100644
index 0000000000000..bd7bd68e7b632
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/single-input-internal-conflict.test
@@ -0,0 +1,26 @@
+# A single input file containing two overlapping replacements (same file,
+# offset, and length but 
diff erent replacement text). The library detects
+# this as a within-input conflict and drops one; the tool reports the
+# cluster on stderr and exits 0.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# RUN: cp %S/Inputs/internal-conflict.tpl.yaml %t.dir/a.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml
+
+# RUN: clang-ssaf-src-edit-merge -o %t.dir/merged.yaml %t.dir/a.yaml \
+# RUN:   2> %t.dir/stderr.txt
+
+# Drop-all: zero replacements survive at the conflict offset (both the X
+# and Y candidates are removed).
+# RUN: FileCheck --input-file=%t.dir/merged.yaml %s
+# CHECK-NOT: ReplacementText:
+
+# Cluster summary line emitted with the full cluster size (2).
+# RUN: FileCheck --input-file=%t.dir/stderr.txt --check-prefix=CONFLICT %s
+# CONFLICT: conflict:
+# CONFLICT-SAME: skipped
+# CONFLICT-SAME: 2
+# CONFLICT-SAME: foo.cpp:10

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/single-input.test 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/single-input.test
new file mode 100644
index 0000000000000..f571dfeb72a3d
--- /dev/null
+++ b/clang-tools-extra/test/clang-ssaf-src-edit-merge/single-input.test
@@ -0,0 +1,23 @@
+# Single input passes through (modulo deterministic ordering). Spec:
+# "If exactly one input is given, the tool SHALL still produce a one-run
+# merged document equivalent to that input."
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# RUN: cp %S/Inputs/single-replacement.tpl.yaml %t.dir/a.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml
+
+# RUN: clang-ssaf-src-edit-merge -o %t.dir/merged.yaml %t.dir/a.yaml \
+# RUN:   2> %t.dir/stderr.txt
+
+# RUN: FileCheck --input-file=%t.dir/merged.yaml %s
+# CHECK: MainSourceFile:
+# CHECK-SAME: foo.cpp
+# CHECK: Offset:
+# CHECK-SAME: 10
+# CHECK: ReplacementText: X
+
+# RUN: FileCheck --input-file=%t.dir/stderr.txt --allow-empty 
--check-prefix=NOSTDERR %s
+# NOSTDERR-NOT: conflict:

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/three-input-all-conflict.test
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/three-input-all-conflict.test
new file mode 100644
index 0000000000000..3c1611453bc7b
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/three-input-all-conflict.test
@@ -0,0 +1,30 @@
+# Three inputs all conflict pairwise at the same (file, offset, length).
+# Per spec ("All inputs conflict pairwise"): under the tool's drop-all
+# policy every candidate is removed from the merged YAML; the tool emits
+# a "conflict:" line for the (file, offset) cluster with the full cluster
+# size (3) and exits 0.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# RUN: cp %S/Inputs/single-replacement.tpl.yaml %t.dir/a.yaml
+# RUN: cp %S/Inputs/conflict-text-y.tpl.yaml %t.dir/b.yaml
+# RUN: cp %S/Inputs/conflict-text-z.tpl.yaml %t.dir/c.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" \
+# RUN:           %t.dir/a.yaml %t.dir/b.yaml %t.dir/c.yaml
+
+# RUN: clang-ssaf-src-edit-merge \
+# RUN:     -o %t.dir/merged.yaml %t.dir/a.yaml %t.dir/b.yaml %t.dir/c.yaml \
+# RUN:   2> %t.dir/stderr.txt
+
+# Drop-all: zero replacements survive at the conflict offset.
+# RUN: FileCheck --input-file=%t.dir/merged.yaml %s
+# CHECK-NOT: ReplacementText:
+
+# Tool's conflict summary names the cluster with the full cluster size (3).
+# RUN: FileCheck --input-file=%t.dir/stderr.txt --check-prefix=CONFLICT %s
+# CONFLICT: conflict:
+# CONFLICT-SAME: skipped
+# CONFLICT-SAME: 3
+# CONFLICT-SAME: foo.cpp:10

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/three-input-conflict-sarif.test
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/three-input-conflict-sarif.test
new file mode 100644
index 0000000000000..b45299601c80e
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/three-input-conflict-sarif.test
@@ -0,0 +1,33 @@
+# Three inputs all conflict pairwise at (foo.cpp, 10) — drop-all removes
+# all three from the merged YAML; SARIF result lists three relatedLocations.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# RUN: cp %S/Inputs/single-replacement.tpl.yaml %t.dir/a.yaml
+# RUN: cp %S/Inputs/conflict-text-y.tpl.yaml %t.dir/b.yaml
+# RUN: cp %S/Inputs/conflict-text-z.tpl.yaml %t.dir/c.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" \
+# RUN:           %t.dir/a.yaml %t.dir/b.yaml %t.dir/c.yaml
+
+# RUN: clang-ssaf-src-edit-merge \
+# RUN:     -o %t.dir/merged.yaml \
+# RUN:     --sarif-conflicts-out=%t.dir/conflicts.sarif \
+# RUN:     %t.dir/a.yaml %t.dir/b.yaml %t.dir/c.yaml 2> %t.dir/stderr.txt
+
+# Drop-all unchanged: all three candidates are removed at the conflict offset.
+# RUN: FileCheck --input-file=%t.dir/merged.yaml --check-prefix=MERGED %s
+# MERGED-NOT: ReplacementText:
+
+# SARIF result lists three relatedLocations (one per cluster member).
+# RUN: FileCheck --input-file=%t.dir/conflicts.sarif --check-prefix=SARIF %s
+# SARIF: "results":
+# SARIF: "level": "error"
+# SARIF: "byteOffset": 10
+# SARIF: "relatedLocations":
+# SARIF: "id": 1
+# SARIF: "id": 2
+# SARIF: "id": 3
+# SARIF: "ruleId": "clang-reforge-replacement-conflict"
+# SARIF: "version": "2.1.0"

diff  --git a/clang-tools-extra/test/clang-ssaf-src-edit-merge/two-conflicts-
diff erent-offsets-sarif.test 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/two-conflicts-
diff erent-offsets-sarif.test
new file mode 100644
index 0000000000000..6f86a54d561ae
--- /dev/null
+++ b/clang-tools-extra/test/clang-ssaf-src-edit-merge/two-conflicts-
diff erent-offsets-sarif.test
@@ -0,0 +1,43 @@
+# Four inputs producing two distinct conflict clusters in the same file:
+# cluster {X, Y} at offset 10 and cluster {P, Q} at offset 30. Drop-all
+# removes all four; SARIF has two results, ordered by (file, min-offset)
+# ascending so the offset-10 cluster precedes the offset-30 cluster.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# RUN: cp %S/Inputs/single-replacement.tpl.yaml %t.dir/a.yaml
+# RUN: cp %S/Inputs/conflict-text-y.tpl.yaml %t.dir/b.yaml
+# RUN: cp %S/Inputs/single-replacement-offset-30.tpl.yaml %t.dir/c.yaml
+# RUN: cp %S/Inputs/conflict-text-q-offset-30.tpl.yaml %t.dir/d.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" \
+# RUN:           %t.dir/a.yaml %t.dir/b.yaml %t.dir/c.yaml %t.dir/d.yaml
+
+# RUN: clang-ssaf-src-edit-merge \
+# RUN:     -o %t.dir/merged.yaml \
+# RUN:     --sarif-conflicts-out=%t.dir/conflicts.sarif \
+# RUN:     %t.dir/a.yaml %t.dir/b.yaml %t.dir/c.yaml %t.dir/d.yaml \
+# RUN:   2> %t.dir/stderr.txt
+
+# Drop-all: zero replacements survive at either conflict offset.
+# RUN: FileCheck --input-file=%t.dir/merged.yaml --check-prefix=MERGED %s
+# MERGED-NOT: ReplacementText:
+
+# Two SARIF results in spec-pinned (file, min-offset) order — byteOffset 10
+# first, byteOffset 30 second. Each has two relatedLocations entries
+# (sorted by (byteLength, text) — id 1 then id 2 within each result).
+# RUN: FileCheck --input-file=%t.dir/conflicts.sarif --check-prefix=SARIF %s
+# SARIF: "results":
+# SARIF: "level": "error"
+# SARIF: "byteOffset": 10
+# SARIF: "relatedLocations":
+# SARIF: "id": 1
+# SARIF: "id": 2
+# SARIF: "ruleId": "clang-reforge-replacement-conflict"
+# SARIF: "level": "error"
+# SARIF: "byteOffset": 30
+# SARIF: "relatedLocations":
+# SARIF: "id": 1
+# SARIF: "id": 2
+# SARIF: "ruleId": "clang-reforge-replacement-conflict"

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/two-input-conflict-sarif.test
 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/two-input-conflict-sarif.test
new file mode 100644
index 0000000000000..43c6093ed3ed7
--- /dev/null
+++ 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/two-input-conflict-sarif.test
@@ -0,0 +1,39 @@
+# Two inputs with overlapping replacements (same offset, 
diff erent text) —
+# drop-all removes both from the merged YAML, the tool emits a one-line
+# stderr cluster summary, AND writes a SARIF 2.1.0 document at the path
+# given by --sarif-conflicts-out= containing one error-level result with
+# two relatedLocations (one per dropped candidate).
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# RUN: cp %S/Inputs/single-replacement.tpl.yaml %t.dir/a.yaml
+# RUN: cp %S/Inputs/conflict-text-y.tpl.yaml %t.dir/b.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml %t.dir/b.yaml
+
+# RUN: clang-ssaf-src-edit-merge \
+# RUN:     -o %t.dir/merged.yaml \
+# RUN:     --sarif-conflicts-out=%t.dir/conflicts.sarif \
+# RUN:     %t.dir/a.yaml %t.dir/b.yaml 2> %t.dir/stderr.txt
+
+# Drop-all unchanged: zero replacements survive at the conflict offset.
+# RUN: FileCheck --input-file=%t.dir/merged.yaml --check-prefix=MERGED %s
+# MERGED-NOT: ReplacementText:
+
+# SARIF doc has the expected top-level shape and exactly one result. Note:
+# llvm::json::Object emits its keys in alphabetical order, so within each
+# result the field order is level, locations, message, relatedLocations,
+# ruleId. Top-level keys are $schema, runs, version (alphabetical too).
+# RUN: FileCheck --input-file=%t.dir/conflicts.sarif --check-prefix=SARIF %s
+# SARIF: "$schema": "https://json.schemastore.org/sarif-2.1.0.json";
+# SARIF: "results":
+# SARIF: "level": "error"
+# SARIF: "uri": "file://{{.*}}/foo.cpp"
+# SARIF: "byteOffset": 10
+# SARIF: "relatedLocations":
+# SARIF: "id": 1
+# SARIF: "id": 2
+# SARIF: "ruleId": "clang-reforge-replacement-conflict"
+# SARIF: "name": "clang-ssaf-src-edit-merge"
+# SARIF: "version": "2.1.0"

diff  --git 
a/clang-tools-extra/test/clang-ssaf-src-edit-merge/two-input-conflict.test 
b/clang-tools-extra/test/clang-ssaf-src-edit-merge/two-input-conflict.test
new file mode 100644
index 0000000000000..b6e8fa5a5fae8
--- /dev/null
+++ b/clang-tools-extra/test/clang-ssaf-src-edit-merge/two-input-conflict.test
@@ -0,0 +1,29 @@
+# Two inputs with overlapping replacements (same file, same offset, same
+# length, but 
diff erent replacement text) — under the tool's drop-all
+# conflict policy both candidates are removed from the merged YAML; the
+# tool emits one "conflict:" cluster line for that (file, offset) with the
+# full cluster size, and exits 0.
+
+# RUN: rm -rf %t.dir
+# RUN: mkdir -p %t.dir
+# RUN: printf '%%80s' '' > %t.dir/foo.cpp
+
+# RUN: cp %S/Inputs/single-replacement.tpl.yaml %t.dir/a.yaml
+# RUN: cp %S/Inputs/conflict-text-y.tpl.yaml %t.dir/b.yaml
+# RUN: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml %t.dir/b.yaml
+
+# RUN: clang-ssaf-src-edit-merge -o %t.dir/merged.yaml %t.dir/a.yaml 
%t.dir/b.yaml \
+# RUN:   2> %t.dir/stderr.txt
+
+# Drop-all: zero replacements survive at the conflict offset.
+# RUN: FileCheck --input-file=%t.dir/merged.yaml %s
+# CHECK-NOT: ReplacementText:
+
+# Tool emits the cluster summary on stderr (alongside the library's own
+# detailed message which we do not assert on, since it is owned by
+# clang-apply-replacements and may evolve).
+# RUN: FileCheck --input-file=%t.dir/stderr.txt --check-prefix=CONFLICT %s
+# CONFLICT: conflict:
+# CONFLICT-SAME: skipped
+# CONFLICT-SAME: 2
+# CONFLICT-SAME: foo.cpp:10


        
_______________________________________________
cfe-commits mailing list
[email protected]
https://lists.llvm.org/cgi-bin/mailman/listinfo/cfe-commits

Reply via email to