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 <[email protected]> Date: Thu, 13 Aug 2026 14:33:48 -0700 Subject: [PATCH 1/3] [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 <[email protected]> Date: Thu, 13 Aug 2026 14:45:06 -0700 Subject: [PATCH 2/3] 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 <[email protected]> Date: Thu, 13 Aug 2026 15:02:55 -0700 Subject: [PATCH 3/3] 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 _______________________________________________ cfe-commits mailing list [email protected] https://lists.llvm.org/cgi-bin/mailman/listinfo/cfe-commits
