[clang-tools-extra] [clang][ssaf][clang-reforge] Tool for merging per-TU source-edit files (PR #216183)
Rashmi Mudduluru via cfe-commits
cfe-commits at lists.llvm.org
Thu Aug 13 15:54:01 PDT 2026
https://github.com/t-rasmud updated https://github.com/llvm/llvm-project/pull/216183
>From 714c64f135019cd0aacb550726bb52a90a01a508 Mon Sep 17 00:00:00 2001
From: Rashmi Mudduluru <r_mudduluru at apple.com>
Date: Thu, 13 Aug 2026 14:33:48 -0700
Subject: [PATCH 1/5] [clang][ssaf][clang-reforge] Tool for merging per-TU
source-edit files
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
---
clang-tools-extra/CMakeLists.txt | 1 +
.../clang-ssaf-src-edit-merge/CMakeLists.txt | 26 +
.../SrcEditMerge.cpp | 567 ++++++++++++++++++
.../Inputs/conflict-text-q-offset-30.tpl.yaml | 8 +
.../Inputs/conflict-text-y.tpl.yaml | 8 +
.../Inputs/conflict-text-z.tpl.yaml | 8 +
.../Inputs/empty.tpl.yaml | 4 +
.../Inputs/internal-conflict.tpl.yaml | 12 +
.../Inputs/non-overlapping-a.tpl.yaml | 8 +
.../Inputs/non-overlapping-b.tpl.yaml | 8 +
.../range-overlap-cluster-of-three.tpl.yaml | 16 +
.../range-overlap-distinct-offsets.tpl.yaml | 12 +
...ingle-non-zero-length-replacement.tpl.yaml | 8 +
.../single-replacement-offset-30.tpl.yaml | 8 +
.../Inputs/single-replacement.tpl.yaml | 8 +
.../Inputs/single-zero-length-insert.tpl.yaml | 8 +
.../clang-ssaf-src-edit-merge/bad-args.test | 10 +
...reserves-unrelated-zero-length-insert.test | 44 ++
.../conflict-stderr-without-sarif.test | 29 +
.../empty-input.test | 24 +
.../test/clang-ssaf-src-edit-merge/help.test | 9 +
.../identical-overlap.test | 26 +
.../clang-ssaf-src-edit-merge/lit.local.cfg | 1 +
.../malformed-yaml.test | 19 +
.../no-conflict-sarif-empty.test | 35 ++
.../non-overlapping.test | 31 +
.../order-invariance.test | 19 +
.../parent-dir-missing.test | 19 +
.../pre-dedup-identical-non-zero-length.test | 28 +
.../pre-dedup-zero-length-insert.test | 33 +
.../range-overlap-cluster-of-three-sarif.test | 63 ++
.../range-overlap-conflict-sarif.test | 53 ++
.../sarif-out-parent-dir-missing.test | 21 +
.../single-input-internal-conflict.test | 26 +
.../single-input.test | 23 +
.../three-input-all-conflict.test | 30 +
.../three-input-conflict-sarif.test | 33 +
...two-conflicts-different-offsets-sarif.test | 43 ++
.../two-input-conflict-sarif.test | 39 ++
.../two-input-conflict.test | 29 +
40 files changed, 1397 insertions(+)
create mode 100644 clang-tools-extra/clang-ssaf-src-edit-merge/CMakeLists.txt
create mode 100644 clang-tools-extra/clang-ssaf-src-edit-merge/SrcEditMerge.cpp
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/conflict-text-q-offset-30.tpl.yaml
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/conflict-text-y.tpl.yaml
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/conflict-text-z.tpl.yaml
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/empty.tpl.yaml
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/internal-conflict.tpl.yaml
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/non-overlapping-a.tpl.yaml
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/non-overlapping-b.tpl.yaml
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/range-overlap-cluster-of-three.tpl.yaml
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/range-overlap-distinct-offsets.tpl.yaml
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-non-zero-length-replacement.tpl.yaml
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-replacement-offset-30.tpl.yaml
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-replacement.tpl.yaml
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/Inputs/single-zero-length-insert.tpl.yaml
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/bad-args.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/conflict-preserves-unrelated-zero-length-insert.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/conflict-stderr-without-sarif.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/empty-input.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/help.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/identical-overlap.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/lit.local.cfg
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/malformed-yaml.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/no-conflict-sarif-empty.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/non-overlapping.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/order-invariance.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/parent-dir-missing.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/pre-dedup-identical-non-zero-length.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/pre-dedup-zero-length-insert.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/range-overlap-cluster-of-three-sarif.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/range-overlap-conflict-sarif.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/sarif-out-parent-dir-missing.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/single-input-internal-conflict.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/single-input.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/three-input-all-conflict.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/three-input-conflict-sarif.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/two-conflicts-different-offsets-sarif.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/two-input-conflict-sarif.test
create mode 100644 clang-tools-extra/test/clang-ssaf-src-edit-merge/two-input-conflict.test
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..7db22f5f8c665
--- /dev/null
+++ b/clang-tools-extra/clang-ssaf-src-edit-merge/CMakeLists.txt
@@ -0,0 +1,26 @@
+set(LLVM_LINK_COMPONENTS
+ Support
+ )
+
+add_clang_tool(clang-ssaf-src-edit-merge
+ SrcEditMerge.cpp
+ )
+
+# clang-apply-replacements headers live under <peer-tool>/include/ and aren't
+# globally exported; expose them only to this tool.
+target_include_directories(clang-ssaf-src-edit-merge
+ PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}/../clang-apply-replacements/include
+ )
+
+clang_target_link_libraries(clang-ssaf-src-edit-merge
+ PRIVATE
+ clangBasic
+ clangToolingCore
+ clangToolingRefactoring
+ )
+
+target_link_libraries(clang-ssaf-src-edit-merge
+ PRIVATE
+ clangApplyReplacements
+ )
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..c348cf8c9346a
--- /dev/null
+++ b/clang-tools-extra/clang-ssaf-src-edit-merge/SrcEditMerge.cpp
@@ -0,0 +1,567 @@
+//===- 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 via the in-tree clang-apply-replacements
+// library (specifically clang::replace::mergeAndDeduplicate), and writes a
+// single merged YAML. 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. After
+// mergeAndDeduplicate runs, the tool computes conflict clusters from the
+// input keys — a cluster being a maximal connected component of input
+// Replacements (within one file) whose [offset, offset+length) byte ranges
+// transitively overlap, restricted to length > 0 entries. For each
+// reportable cluster (one whose members intersect the library's
+// merged-output keys), every member is removed from the merged YAML,
+// one summary line is emitted to stderr, and the tool exits 0.
+//
+// Zero-length insertions are out of scope for the drop-all policy in this
+// change; they continue to follow clang-apply-replacements' own
+// IgnoreInsertConflict=false (first-registered) policy.
+//
+//===----------------------------------------------------------------------===//
+
+#include "clang-apply-replacements/Tooling/ApplyReplacements.h"
+#include "clang/Basic/Diagnostic.h"
+#include "clang/Basic/DiagnosticOptions.h"
+#include "clang/Basic/FileManager.h"
+#include "clang/Basic/SourceManager.h"
+#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/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;
+
+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 2.1.0 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() << "clang-ssaf-src-edit-merge: cannot read " << Path << ": "
+ << EC.message() << "\n";
+ return false;
+ }
+ llvm::yaml::Input YAML(Buffer.get()->getBuffer());
+ YAML >> Out;
+ if (YAML.error()) {
+ llvm::errs() << "clang-ssaf-src-edit-merge: " << Path
+ << ": invalid TranslationUnitReplacements YAML\n";
+ return false;
+ }
+ return true;
+}
+
+/// Identifying tuple for a replacement, used for both conflict-cluster
+/// analysis (input side) and drop-all filtering against the library's
+/// merged-output keys.
+struct ReplacementKey {
+ std::string FilePath;
+ unsigned Offset;
+ unsigned Length;
+ std::string Text;
+
+ bool operator<(const ReplacementKey &Other) const {
+ if (FilePath != Other.FilePath)
+ return FilePath < Other.FilePath;
+ if (Offset != Other.Offset)
+ return Offset < Other.Offset;
+ if (Length != Other.Length)
+ return Length < Other.Length;
+ return Text < Other.Text;
+ }
+};
+
+ReplacementKey makeKey(const clang::tooling::Replacement &R) {
+ return ReplacementKey{R.getFilePath().str(), R.getOffset(), R.getLength(),
+ R.getReplacementText().str()};
+}
+
+/// Pull every Replacement out of a FileToChangesMap into a flat ordered
+/// vector. Within each AtomicChange, the underlying Replacements are
+/// already (file, offset)-ordered by the library; across files we order
+/// by file path for determinism.
+std::vector<clang::tooling::Replacement>
+flattenFileChanges(const clang::replace::FileToChangesMap &Changes) {
+ // Group by file first so the output is deterministic across runs.
+ std::map<std::string, std::vector<clang::tooling::Replacement>> ByFile;
+ for (const auto &Entry : Changes) {
+ const std::string Path = Entry.first.getName().str();
+ auto &Bucket = ByFile[Path];
+ for (const clang::tooling::AtomicChange &AC : Entry.second) {
+ for (const clang::tooling::Replacement &R : AC.getReplacements())
+ Bucket.push_back(R);
+ }
+ }
+
+ std::vector<clang::tooling::Replacement> Flat;
+ for (auto &Entry : ByFile) {
+ auto &Bucket = Entry.second;
+ // Sort within a file by (offset, length, text) so per-file order is
+ // deterministic regardless of input order.
+ llvm::sort(Bucket, [](const clang::tooling::Replacement &A,
+ const clang::tooling::Replacement &B) {
+ if (A.getOffset() != B.getOffset())
+ return A.getOffset() < B.getOffset();
+ if (A.getLength() != B.getLength())
+ return A.getLength() < B.getLength();
+ return A.getReplacementText() < B.getReplacementText();
+ });
+ for (const auto &R : Bucket)
+ Flat.push_back(R);
+ }
+ return Flat;
+}
+
+/// 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.
+///
+/// A cluster is a maximal connected component of input replacements (within
+/// one file) whose [offset, offset+length) byte ranges transitively overlap.
+/// Only length > 0 keys participate; zero-length insertions are out of scope
+/// for the drop-all policy per spec and continue to follow the library's
+/// existing IgnoreInsertConflict=false (first-registered) policy.
+///
+/// The walk groups by file, sorts each file's keys by (offset, length), and
+/// 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, satisfying the spec's argv-permutation
+/// invariance promise.
+std::vector<std::vector<ReplacementKey>>
+buildConflictClusters(const std::set<ReplacementKey> &InputKeys) {
+ // Group length > 0 keys by file.
+ std::map<std::string, std::vector<ReplacementKey>> ByFile;
+ for (const ReplacementKey &K : InputKeys) {
+ if (K.Length == 0)
+ continue;
+ ByFile[K.FilePath].push_back(K);
+ }
+
+ std::vector<std::vector<ReplacementKey>> Clusters;
+ for (auto &Entry : ByFile) {
+ auto &Keys = Entry.second;
+ // InputKeys is a std::set sorted by (file, offset, length, text), so the
+ // per-file vector is already in (offset, length, text) order. Sort
+ // defensively for clarity.
+ llvm::sort(Keys, [](const ReplacementKey &A, const ReplacementKey &B) {
+ if (A.Offset != B.Offset)
+ return A.Offset < B.Offset;
+ if (A.Length != B.Length)
+ return A.Length < B.Length;
+ return A.Text < B.Text;
+ });
+
+ std::vector<ReplacementKey> Current;
+ unsigned LastEnd = 0;
+ auto Flush = [&]() {
+ if (Current.size() > 1)
+ Clusters.push_back(std::move(Current));
+ Current.clear();
+ LastEnd = 0;
+ };
+
+ for (const ReplacementKey &K : Keys) {
+ if (Current.empty()) {
+ Current.push_back(K);
+ LastEnd = K.Offset + K.Length;
+ continue;
+ }
+ if (K.Offset < LastEnd) {
+ Current.push_back(K);
+ LastEnd = std::max(LastEnd, K.Offset + K.Length);
+ } else {
+ Flush();
+ Current.push_back(K);
+ LastEnd = K.Offset + K.Length;
+ }
+ }
+ Flush();
+ }
+
+ // Pin cluster-list order by (file, min-offset) ascending.
+ llvm::sort(Clusters, [](const std::vector<ReplacementKey> &A,
+ const std::vector<ReplacementKey> &B) {
+ if (A.front().FilePath != B.front().FilePath)
+ return A.front().FilePath < B.front().FilePath;
+ return A.front().Offset < B.front().Offset;
+ });
+
+ return Clusters;
+}
+
+/// Emit one stderr line per reportable conflict cluster.
+///
+/// Format (preserved verbatim from the prior implementation):
+/// conflict: skipped <count> overlapping replacement(s) at <file>:<offset>
+/// where <count> is |cluster| (the full number of dropped members — under
+/// drop-all this is the cluster size, NOT cluster size − 1 as it was when
+/// first-registered let one survive) and <offset> is the cluster's minimum
+/// offset.
+///
+/// Caller is responsible for passing only reportable clusters
+/// (cluster ∩ OutputKeys ≠ ∅) and for the (file, min-offset) sort.
+void emitConflictClusterLines(
+ const std::vector<std::vector<ReplacementKey>> &Clusters) {
+ for (const auto &Cluster : Clusters) {
+ llvm::errs() << "conflict: skipped " << Cluster.size()
+ << " overlapping replacement(s) at " << Cluster.front().FilePath
+ << ":" << Cluster.front().Offset << "\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://" + Buf).str();
+ Buf.assign(FilePath.begin(), FilePath.end());
+ if (!llvm::sys::fs::make_absolute(Buf))
+ return ("file://" + Buf).str();
+ return ("file://" + FilePath).str();
+}
+
+/// Emit a SARIF 2.1.0 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 — this is a different sort from
+/// the cluster's outer `(offset, length, text)` ordering, so members are
+/// re-sorted inside this helper.
+///
+/// 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<ReplacementKey>> Clusters) {
+ llvm::json::Array Results;
+ Results.reserve(Clusters.size());
+
+ for (const auto &Cluster : Clusters) {
+ const ReplacementKey &Min = Cluster.front();
+ std::string Uri = canonicalizeToFileUri(Min.FilePath);
+
+ // Re-sort cluster members locally by (byteLength, text) ascending for
+ // argv-permutation invariance of relatedLocations[].
+ std::vector<ReplacementKey> Sorted(Cluster.begin(), Cluster.end());
+ llvm::sort(Sorted, [](const ReplacementKey &A, const ReplacementKey &B) {
+ if (A.Length != B.Length)
+ return A.Length < B.Length;
+ return A.Text < B.Text;
+ });
+
+ llvm::json::Array RelatedLocations;
+ RelatedLocations.reserve(Sorted.size());
+ for (size_t I = 0; I < Sorted.size(); ++I) {
+ const ReplacementKey &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.Offset)},
+ {"byteLength",
+ static_cast<int64_t>(K.Length)}}}}},
+ {"message",
+ llvm::json::Object{
+ {"text", ("candidate edit: \"" + K.Text + "\"")}}}});
+ }
+
+ std::string MessageText =
+ llvm::formatv("{0} overlapping replacement(s) at {1} byte {2} were "
+ "dropped; resolve manually.",
+ Cluster.size(), Uri, Min.Offset)
+ .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.Offset)}}}}}}}},
+ {"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", "clang-ssaf-src-edit-merge"},
+ {"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, "cannot write " + Path);
+ // 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(), "write error on " + Path);
+ return llvm::Error::success();
+}
+
+} // namespace
+
+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");
+
+ // 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 before the
+ // library merge. clang-apply-replacements' groupReplacements only dedups
+ // TUDiagnostics-sourced replacements; plain TUReplacements are appended
+ // unconditionally, so identical zero-length inserts at the same offset
+ // (e.g., a `.data()` rewrite in a header included by N TUs, or an
+ // `addr_of(...)` wrap closing-paren) get stacked by AtomicChange::replace
+ // into runaway `.data().data()...` or `))))` chains. The first occurrence
+ // (in input-file order, then within-file order) wins; later duplicates are
+ // byte-identical to it in (file, offset, length, text), so the choice is
+ // observationally moot.
+ {
+ std::set<ReplacementKey> 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(makeKey(R)).second)
+ Unique.push_back(R);
+ }
+ TU.Replacements = std::move(Unique);
+ }
+ }
+
+ // Pre-compute the input-side replacement set for conflict reporting.
+ std::set<ReplacementKey> InputKeys;
+ for (const auto &TU : TUs)
+ for (const auto &R : TU.Replacements)
+ InputKeys.insert(makeKey(R));
+
+ // Build a SourceManager for mergeAndDeduplicate.
+ clang::DiagnosticOptions DiagOpts;
+ clang::DiagnosticsEngine Diagnostics(clang::DiagnosticIDs::create(),
+ DiagOpts);
+ clang::FileManager Files((clang::FileSystemOptions()));
+ clang::SourceManager SM(Diagnostics, Files);
+
+ // Run the library's merge. mergeAndDeduplicate's return value (true = no
+ // overlap detected, false = at least one overlap dropped) is no longer
+ // consulted directly. The tool's drop-all policy operates on its own
+ // cluster analysis below; the library's first-registered behavior is
+ // overridden by removing every cluster member from OutDoc.Replacements.
+ // The library's return value still drives its own per-Replacement stderr
+ // diagnostics, which we leave intact.
+ clang::replace::FileToChangesMap FileChanges;
+ const clang::replace::TUDiagnostics NoDiagnostics;
+ (void)clang::replace::mergeAndDeduplicate(TUs, NoDiagnostics, FileChanges, SM,
+ /*IgnoreInsertConflict=*/false);
+
+ // Flatten the merged FileChanges back into a TranslationUnitReplacements.
+ clang::tooling::TranslationUnitReplacements OutDoc;
+ OutDoc.MainSourceFile = computeMainSourceFile(TUs);
+ OutDoc.Replacements = flattenFileChanges(FileChanges);
+
+ // Drop-all conflict handling.
+ //
+ // Step 1: build conflict clusters from the input key set.
+ std::vector<std::vector<ReplacementKey>> Clusters =
+ buildConflictClusters(InputKeys);
+
+ // Step 2: compute OutputKeys = set of ReplacementKey for every entry in
+ // the library's merged FileChanges, BEFORE drop-all filtering. This is
+ // the cluster-eligibility predicate's right-hand side.
+ std::set<ReplacementKey> OutputKeys;
+ for (const auto &R : OutDoc.Replacements)
+ OutputKeys.insert(makeKey(R));
+
+ // Step 3: a cluster is reportable iff at least one of its members appears
+ // in OutputKeys (cluster ∩ OutputKeys ≠ ∅). This eligibility predicate
+ // replaces the old `!MergeOk` gate: file-not-found inputs are silently
+ // filtered by the library during groupReplacements, so neither member
+ // appears in OutputKeys, so the cluster drops out of the report path
+ // naturally — no need to consult mergeAndDeduplicate's return value.
+ std::vector<std::vector<ReplacementKey>> ReportableClusters;
+ ReportableClusters.reserve(Clusters.size());
+ for (auto &Cluster : Clusters) {
+ bool Reportable = false;
+ for (const ReplacementKey &K : Cluster) {
+ if (OutputKeys.count(K)) {
+ Reportable = true;
+ break;
+ }
+ }
+ if (Reportable)
+ ReportableClusters.push_back(std::move(Cluster));
+ }
+
+ // Step 4: build the exact key set for every reportable cluster member.
+ // Drop-all then strips every matching entry from OutDoc. Keying on the
+ // full ReplacementKey (not just (file, offset)) matters because a
+ // zero-length insertion can share an offset with an unrelated conflict
+ // cluster (zero-length ranges never overlap anything, so they're never
+ // cluster members) — keying on (file, offset) alone would collaterally
+ // delete that insertion too.
+ std::set<ReplacementKey> KeysToRemove;
+ for (const auto &Cluster : ReportableClusters)
+ for (const ReplacementKey &K : Cluster)
+ KeysToRemove.insert(K);
+
+ if (!KeysToRemove.empty()) {
+ auto &Reps = OutDoc.Replacements;
+ Reps.erase(std::remove_if(Reps.begin(), Reps.end(),
+ [&](const clang::tooling::Replacement &R) {
+ return KeysToRemove.count(makeKey(R)) > 0;
+ }),
+ Reps.end());
+ }
+
+ // Step 5: emit stderr cluster lines. ReportableClusters was sorted by
+ // (file, min-offset) ascending inside buildConflictClusters; the
+ // reportability filter preserved that order.
+ emitConflictClusterLines(ReportableClusters);
+
+ // Step 6: when --sarif-conflicts-out=<path> was supplied, write the
+ // SARIF document. Empty ReportableClusters 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, ReportableClusters)) {
+ llvm::errs() << "clang-ssaf-src-edit-merge: "
+ << 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() << "clang-ssaf-src-edit-merge: cannot write " << OutputFile
+ << ": " << EC.message() << "\n";
+ return 1;
+ }
+ llvm::yaml::Output YAML(OutStream);
+ YAML << OutDoc;
+ OutStream.flush();
+ if (OutStream.has_error()) {
+ llvm::errs() << "clang-ssaf-src-edit-merge: write error on " << OutputFile
+ << "\n";
+ return 1;
+ }
+
+ return 0;
+}
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..213d26f686a2b
--- /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..be40c3e62062f
--- /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..7b58b00ae5b23
--- /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..310d5ae070490
--- /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..1d0f48ad6d13f
--- /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..678a202d0cbeb
--- /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..7c7df50de60b3
--- /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 different 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..9510fcb24a9ad
--- /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..30ba1279249a5
--- /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..1a266368fdac1
--- /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..6c3adf42ef71f
--- /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..192a6f68fa23b
--- /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..75a2adfd94f48
--- /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..bc94defa2f1ef
--- /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 different 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..028b5ffa65d43
--- /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..eeccdde37e570
--- /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..e26115f56a056
--- /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-different-offsets-sarif.test b/clang-tools-extra/test/clang-ssaf-src-edit-merge/two-conflicts-different-offsets-sarif.test
new file mode 100644
index 0000000000000..88a29067eaceb
--- /dev/null
+++ b/clang-tools-extra/test/clang-ssaf-src-edit-merge/two-conflicts-different-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..17eebf7c04bc6
--- /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, different 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..f498ac72e7f63
--- /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 different 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
>From 2e35a202cbfaa1f14b666b402f2de172975acb24 Mon Sep 17 00:00:00 2001
From: Rashmi Mudduluru <r_mudduluru at apple.com>
Date: Thu, 13 Aug 2026 14:45:06 -0700
Subject: [PATCH 2/5] Fix formatting
---
.../SrcEditMerge.cpp | 38 +++++++++----------
1 file changed, 19 insertions(+), 19 deletions(-)
diff --git a/clang-tools-extra/clang-ssaf-src-edit-merge/SrcEditMerge.cpp b/clang-tools-extra/clang-ssaf-src-edit-merge/SrcEditMerge.cpp
index c348cf8c9346a..3b07a7a4b341f 100644
--- a/clang-tools-extra/clang-ssaf-src-edit-merge/SrcEditMerge.cpp
+++ b/clang-tools-extra/clang-ssaf-src-edit-merge/SrcEditMerge.cpp
@@ -150,7 +150,7 @@ flattenFileChanges(const clang::replace::FileToChangesMap &Changes) {
// Sort within a file by (offset, length, text) so per-file order is
// deterministic regardless of input order.
llvm::sort(Bucket, [](const clang::tooling::Replacement &A,
- const clang::tooling::Replacement &B) {
+ const clang::tooling::Replacement &B) {
if (A.getOffset() != B.getOffset())
return A.getOffset() < B.getOffset();
if (A.getLength() != B.getLength())
@@ -276,8 +276,9 @@ void emitConflictClusterLines(
const std::vector<std::vector<ReplacementKey>> &Clusters) {
for (const auto &Cluster : Clusters) {
llvm::errs() << "conflict: skipped " << Cluster.size()
- << " overlapping replacement(s) at " << Cluster.front().FilePath
- << ":" << Cluster.front().Offset << "\n";
+ << " overlapping replacement(s) at "
+ << Cluster.front().FilePath << ":" << Cluster.front().Offset
+ << "\n";
}
}
@@ -317,9 +318,9 @@ std::string canonicalizeToFileUri(llvm::StringRef FilePath) {
/// 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<ReplacementKey>> Clusters) {
+llvm::Error
+emitConflictSarif(llvm::StringRef Path,
+ llvm::ArrayRef<std::vector<ReplacementKey>> Clusters) {
llvm::json::Array Results;
Results.reserve(Clusters.size());
@@ -345,13 +346,12 @@ llvm::Error emitConflictSarif(
{"physicalLocation",
llvm::json::Object{
{"artifactLocation", llvm::json::Object{{"uri", Uri}}},
- {"region", llvm::json::Object{
- {"byteOffset", static_cast<int64_t>(K.Offset)},
- {"byteLength",
- static_cast<int64_t>(K.Length)}}}}},
- {"message",
- llvm::json::Object{
- {"text", ("candidate edit: \"" + K.Text + "\"")}}}});
+ {"region",
+ llvm::json::Object{
+ {"byteOffset", static_cast<int64_t>(K.Offset)},
+ {"byteLength", static_cast<int64_t>(K.Length)}}}}},
+ {"message", llvm::json::Object{
+ {"text", ("candidate edit: \"" + K.Text + "\"")}}}});
}
std::string MessageText =
@@ -371,8 +371,7 @@ llvm::Error emitConflictSarif(
{"artifactLocation", llvm::json::Object{{"uri", Uri}}},
{"region",
llvm::json::Object{
- {"byteOffset",
- static_cast<int64_t>(Min.Offset)}}}}}}}},
+ {"byteOffset", static_cast<int64_t>(Min.Offset)}}}}}}}},
{"relatedLocations", std::move(RelatedLocations)}});
}
@@ -381,10 +380,11 @@ llvm::Error emitConflictSarif(
{"$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", "clang-ssaf-src-edit-merge"},
- {"version", CLANG_VERSION_STRING}}}}},
+ {"tool",
+ llvm::json::Object{
+ {"driver",
+ llvm::json::Object{{"name", "clang-ssaf-src-edit-merge"},
+ {"version", CLANG_VERSION_STRING}}}}},
{"results", std::move(Results)}}}}};
std::error_code EC;
>From e1e3e2a72588f5dbf62617a35def912b43f8b80f Mon Sep 17 00:00:00 2001
From: Rashmi Mudduluru <r_mudduluru at apple.com>
Date: Thu, 13 Aug 2026 15:02:55 -0700
Subject: [PATCH 3/5] fix test failure
---
clang-tools-extra/test/CMakeLists.txt | 1 +
1 file changed, 1 insertion(+)
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
>From 4d1a3cec16f1fa9384521cb59b60f199434c9501 Mon Sep 17 00:00:00 2001
From: Rashmi Mudduluru <r_mudduluru at apple.com>
Date: Thu, 13 Aug 2026 15:30:48 -0700
Subject: [PATCH 4/5] fix tests for windows platform
---
.../conflict-preserves-unrelated-zero-length-insert.test | 2 +-
.../conflict-stderr-without-sarif.test | 2 +-
.../test/clang-ssaf-src-edit-merge/empty-input.test | 2 +-
.../test/clang-ssaf-src-edit-merge/identical-overlap.test | 2 +-
.../test/clang-ssaf-src-edit-merge/no-conflict-sarif-empty.test | 2 +-
.../test/clang-ssaf-src-edit-merge/non-overlapping.test | 2 +-
.../test/clang-ssaf-src-edit-merge/order-invariance.test | 2 +-
.../test/clang-ssaf-src-edit-merge/parent-dir-missing.test | 2 +-
.../pre-dedup-identical-non-zero-length.test | 2 +-
.../clang-ssaf-src-edit-merge/pre-dedup-zero-length-insert.test | 2 +-
.../range-overlap-cluster-of-three-sarif.test | 2 +-
.../clang-ssaf-src-edit-merge/range-overlap-conflict-sarif.test | 2 +-
.../clang-ssaf-src-edit-merge/sarif-out-parent-dir-missing.test | 2 +-
.../single-input-internal-conflict.test | 2 +-
.../test/clang-ssaf-src-edit-merge/single-input.test | 2 +-
.../clang-ssaf-src-edit-merge/three-input-all-conflict.test | 2 +-
.../clang-ssaf-src-edit-merge/three-input-conflict-sarif.test | 2 +-
.../two-conflicts-different-offsets-sarif.test | 2 +-
.../clang-ssaf-src-edit-merge/two-input-conflict-sarif.test | 2 +-
.../test/clang-ssaf-src-edit-merge/two-input-conflict.test | 2 +-
20 files changed, 20 insertions(+), 20 deletions(-)
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
index 213d26f686a2b..871498e4d3e33 100644
--- 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
@@ -20,7 +20,7 @@
# 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: 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 \
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
index be40c3e62062f..1067c49e37a54 100644
--- 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
@@ -8,7 +8,7 @@
# 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: 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 \
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
index 7b58b00ae5b23..ba08d60ade242 100644
--- 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
@@ -8,7 +8,7 @@
# 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: 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
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
index 310d5ae070490..99427f0459b26 100644
--- 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
@@ -9,7 +9,7 @@
# 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: 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
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
index 1d0f48ad6d13f..9575025ee00f3 100644
--- 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
@@ -8,7 +8,7 @@
# 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: 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 \
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
index 678a202d0cbeb..13310acae189e 100644
--- 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
@@ -10,7 +10,7 @@
# 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: 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
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
index 7c7df50de60b3..9a10eba5c7fc1 100644
--- 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
@@ -9,7 +9,7 @@
# 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: sed -i'' -e "s|@FOO@|%/t.dir/foo.cpp|g" %t.dir/a.yaml %t.dir/b.yaml
# Run the tool twice with different argv orders.
# RUN: clang-ssaf-src-edit-merge -o %t.dir/merged-ab.yaml %t.dir/a.yaml %t.dir/b.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
index 9510fcb24a9ad..1001363d1302e 100644
--- 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
@@ -5,7 +5,7 @@
# 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: 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.
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
index 30ba1279249a5..4444ea0703445 100644
--- 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
@@ -10,7 +10,7 @@
# 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: 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
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
index 1a266368fdac1..a5f9b93b92715 100644
--- 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
@@ -12,7 +12,7 @@
# 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: 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
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
index 6c3adf42ef71f..e4b13b874d5e1 100644
--- 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
@@ -18,7 +18,7 @@
# 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: 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 \
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
index 192a6f68fa23b..d5fe203df7f44 100644
--- 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
@@ -14,7 +14,7 @@
# 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: 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 \
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
index 75a2adfd94f48..0efdbd77d5377 100644
--- 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
@@ -6,7 +6,7 @@
# 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: 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 \
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
index bc94defa2f1ef..bd7bd68e7b632 100644
--- 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
@@ -8,7 +8,7 @@
# 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: 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
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
index 028b5ffa65d43..f571dfeb72a3d 100644
--- 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
@@ -7,7 +7,7 @@
# 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: 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
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
index eeccdde37e570..3c1611453bc7b 100644
--- 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
@@ -11,7 +11,7 @@
# 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: 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 \
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
index e26115f56a056..b45299601c80e 100644
--- 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
@@ -8,7 +8,7 @@
# 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: 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 \
diff --git a/clang-tools-extra/test/clang-ssaf-src-edit-merge/two-conflicts-different-offsets-sarif.test b/clang-tools-extra/test/clang-ssaf-src-edit-merge/two-conflicts-different-offsets-sarif.test
index 88a29067eaceb..6f86a54d561ae 100644
--- a/clang-tools-extra/test/clang-ssaf-src-edit-merge/two-conflicts-different-offsets-sarif.test
+++ b/clang-tools-extra/test/clang-ssaf-src-edit-merge/two-conflicts-different-offsets-sarif.test
@@ -11,7 +11,7 @@
# 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: 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 \
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
index 17eebf7c04bc6..43c6093ed3ed7 100644
--- 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
@@ -10,7 +10,7 @@
# 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: 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 \
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
index f498ac72e7f63..b6e8fa5a5fae8 100644
--- 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
@@ -10,7 +10,7 @@
# 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: 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
>From dea821efe764ed4116bc25a9793f5ea4ec44bc50 Mon Sep 17 00:00:00 2001
From: Rashmi Mudduluru <r_mudduluru at apple.com>
Date: Thu, 13 Aug 2026 15:53:41 -0700
Subject: [PATCH 5/5] Normalize backslashes to forward slashes when building
the file:// URI:
---
.../clang-ssaf-src-edit-merge/SrcEditMerge.cpp | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/clang-tools-extra/clang-ssaf-src-edit-merge/SrcEditMerge.cpp b/clang-tools-extra/clang-ssaf-src-edit-merge/SrcEditMerge.cpp
index 3b07a7a4b341f..5009664a26faf 100644
--- a/clang-tools-extra/clang-ssaf-src-edit-merge/SrcEditMerge.cpp
+++ b/clang-tools-extra/clang-ssaf-src-edit-merge/SrcEditMerge.cpp
@@ -49,6 +49,7 @@
#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"
@@ -298,11 +299,11 @@ void emitConflictClusterLines(
std::string canonicalizeToFileUri(llvm::StringRef FilePath) {
llvm::SmallString<256> Buf;
if (!llvm::sys::fs::real_path(FilePath, Buf))
- return ("file://" + Buf).str();
+ return "file://" + llvm::sys::path::convert_to_slash(Buf);
Buf.assign(FilePath.begin(), FilePath.end());
if (!llvm::sys::fs::make_absolute(Buf))
- return ("file://" + Buf).str();
- return ("file://" + FilePath).str();
+ return "file://" + llvm::sys::path::convert_to_slash(Buf);
+ return "file://" + llvm::sys::path::convert_to_slash(FilePath);
}
/// Emit a SARIF 2.1.0 document at `Path` listing every conflict cluster.
More information about the cfe-commits
mailing list